import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery"; import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount"; import { romNativeNpcCombatant, romNativeTrainingCombatant, romNativePlayerHitProfile, romNativeNpcHitProfile, resolveRomNativeCombatResult, nativeMagicInputsForType, type RomNativeCombatantSnapshot, type RomNativeHitProfile } from "./romNativeCombatResolution"; import { resolveRomNativeEnemyDirectDamage, romNativeTrainingEnemyAbsorbInputs, type RomNativeEnemyDirectDamageValue } from "./romNativeEnemyDamage"; import { nativePeriodicDamageTick, nativePeriodicDamageSnapshot, romNativePeriodicDamageBinding, type RomNativePeriodicDamageSnapshot } from "./romNativePeriodicDamage"; import { create } from "zustand"; import { DEFAULT_GRAPHICS_SETTINGS, normalizeGraphicsSettings, type GraphicsSettings } from "./graphicsSettings"; import { permanentBuffEffects } from "./partyBuffPolicy"; import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore"; import { combatNow } from "./combatClock"; import { selectEnemyChainTargets } from "./enemyChainTargeting"; import { mergePassiveAuraResourceModifiers } from "./classPassiveResourceModifiers"; import { enemyPeriodicDamageSource, enemyPeriodicDamageTickAmount } from "./enemyPeriodicDamage"; import { enemyDamageTypeSourceForSpell } from "./mobDamageTypes"; import { applyEnemySourceAura, enemySourceAuraContract, supportedEnemySourceAuraDefinition, removeEnemyActorAuras } from "./enemySourceAuras"; import { enemyBlockAuraContract, ordinaryCreatureSelfTargetCriticalBlockAuraDefinition, resolveEnemyNpcBlockAuraInputs } from "./enemyBlockAuras"; import { enemyUtilitySpellBlockAbilityContract, enemyUtilitySpellBlockDisposition } from "./enemyUtilitySpellBlocking"; import { isWow335ShieldBashDazeParent, wow335ShieldBashDazeTargetEligible } from "./wow335ShieldBashDaze"; import { sourceFearSummonPosition } from "./enemyFearSummonMovement"; import { enemyControlAbilityCanEscapeAuras, enemyControlImmunityPurgeProofForAbility, purgeNamedEnemyControlShoutAuras } from "./enemyControlImmunityPurge"; import { WOW335_HUNTER_DETERRENCE_ABILITY_ID, WOW335_DETERRENCE_PARENT_AURA_IDS, WOW335_DETERRENCE_PACKAGE_AURA_IDS, WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS, wow335DeterrenceAbilityContract, wow335DeterrenceActivePackage, resolveWow335DeterrenceIncoming } from "./wow335DeterrenceRuntime"; import { enemyTriggeredShoutContract, planEnemyTriggeredShoutTargets, enemyTriggeredShoutControlTag, enemyTriggeredShoutMovementSpeedEffect, resolveEnemyTriggeredShoutControl } from "./enemyTriggeredShouts"; import { ENEMY_DUAL_WIELD_FIRST_SWING_DELAY_FRACTION, ENEMY_DUAL_WIELD_SIMULTANEOUS_SWING_DELAY_MS, ENEMY_DUAL_WIELD_WHITE_MISS_PENALTY_PERCENT, enemyDualWieldContract, enemyThrashContract, enqueueEnemyThrashExtraAttacks, resolveEnemyThrashTarget, trackEnemyLastDamagedTarget, type QueuedEnemyThrashExtraAttacks, } from "./enemyExtraAttacks"; import { enemyMeleeHasteIntervalMultiplier } from "./enemyBloodlust"; import { enemyFlagDrivenOffhandContract } from "./enemyAutomaticOffhand"; import { enemySpawnEquipmentContract, type EnemySpawnEquipmentContract } from "./enemyEquipment"; import { enemyDisarmCanApplyToTarget, enemyWeaponDebuffContract, type EnemyDisarmWeaponChannel } from "./enemyWeaponDebuffs"; import { ENEMY_DISARM_UNARMED_MAIN_HAND, enemyDisarmBlocksAbilityCast, enemyDisarmRequirementCandidates, enemySpellHasMatchingEquippedItem, activeEnemyDisarmChannels, enemyDisarmItemDependentAuraSuppressionIds, enemyDisarmAurasForWeaponChannel, enemyDisarmAbilityWeaponChannel, enemyDisarmAdjustedAbilityWeapon, } from "./enemyDisarmRuntime"; import { enemyDisarmTargetWeaponsFromLoadout } from "./enemyDisarmRuntime"; import { canEnemyNpcBlock, canEnemyNpcBlockSpell, classifyEnemyNpcSpellBlockPhase, enemyNpcBlockChanceAfterSkill, enemyNpcSpellBlockChanceAfterSkill, resolveEnemyNpcBlockStats, type EnemyNpcBlockAttack, type EnemyNpcSpellDefenseMetadata } from "./enemyBlocking"; import { useDungeonSimulationStore } from "./dungeonSimulationStore"; import { useGameStore } from "./store"; import { baseClassFor, contentCategoryForClass, isRomClassId, type ClassId, type GenderId, type RaceId, } from "../app/characterCatalog"; import type { ContentCategoryId } from "../app/contentCategories"; import { normalizeCharacterStatRuleset, type CharacterStatRuleset } from "./characterStatRuleset"; import { applyRomNativeStatModifiers, type RomNativeAdditiveChannel, type RomNativeAttributeKey, type RomNativeDerivedStats, type RomNativeRateChannel, type RomNativeStatModifiers } from "./romNativeStats"; import { advanceRomNativeRecoveryClock, romNativePeriodicManaEffectDuring, romNativeRecoveryPulseAmounts, type RomNativeRecoveryClock, } from "./romNativeRecovery"; import { abilitiesForClass, abilitiesForCharacter, abilitiesUnlockedBetweenLevels, abilityAtLevel, abilityById, abilityRankForLevel, abilityTalentNodeId, defaultActionBarForClass, defaultActionBarForCharacter, isAbilityUnlocked, learnedAbilityForBuild, learnedAbilityRank, naturePowerMaximumForCharacter, resourceProfileForClass, triggeredAbilityBySpellId, type AbilityAmountScaling, type AbilityDamageResource, type AbilityDefinition, type AbilityEffect, type MobStatusKind, type ResourceType, } from "./abilityCatalog"; import { abilityPushbackResistance, abilityRequiresOutOfCombat, canAbilityCastWhileMoving, runeCostForAbility, targetingRulesForAbility, } from "./abilityRules"; import { combatHasLineOfSight, combatSpatialActor, isPositionInFacingArc, isSourceBehindTarget, projectCombatGroundPoint, } from "./combatSpatial"; import { appendCombatEvent, type CombatEventInput, type StructuredCombatEvent, } from "./combatEvents"; import { createDeathKnightRunes, normalizeActorResourcePool, type CombatActorState, type DeathKnightRuneState, } from "./combatActors"; import { companionResourceMaximum, companionActorFromMember, companionActorWithMemberRuntime, memberFromCompanionActor, } from "./combatActorAuthority"; import { activateDeathKnightBloodTapRune, activateDeathKnightRunes, advanceCombatResourcePool, canActivateDeathKnightBloodTapRune, spendDeathKnightRunes, } from "./combatResources"; import { cooldownResetCandidates } from "./cooldownReset"; import { resolveHealing, type HealingResolution } from "./combatHealing"; import { coaBarbarianBeheaderEnergyRefund, coaBarbarianDecapitateTargetAllowed, isCoaBarbarianDecapitate } from "./coaBarbarianExecuteRuntime"; import { coaBarbarianDecapitateWeaponStrike, coaNormalizedMeleeWeaponSpeed } from "./coaBarbarianWeaponDamageRuntime"; import { coaWitchDoctorBrewingAbilityForCast, coaWitchDoctorCanBrewIngredient, isCoaWitchDoctorSplashPotion } from "./coaWitchDoctorBrewingRuntime"; import { applyCoaGuardianVanguardEvent } from "./coaGuardianVanguardRuntime"; import { applyCoaGuardianParagonEvent, projectCoaGuardianParagonAbility } from "./coaGuardianParagonRuntime"; import { coaGuardianFormationAbilityForCast, projectCoaGuardianFormationAuras, resolveCoaGuardianControlDurationMs } from "./coaGuardianFormationRuntime"; import { WOW335_HUNTER_PET_FURIOUS_HOWL_TAG, WOW335_HUNTER_PET_PROWL_TAG, WOW335_HUNTER_PET_RAKE_TAG, wow335HunterPetClawDamage, WOW335_HUNTER_PET_SHELL_SHIELD_TAG, wow335HunterPetFuriousHowlAura, wow335HunterPetFuriousHowlAutoAttackDamageBonus, wow335HunterPetShellShieldAura, wow335HunterPetProwlAura, wow335HunterPetBiteDamage, WOW335_HUNTER_PET_KILL_COMMAND_TAG, wow335HunterPetKillCommandBonusPercent, wow335HunterPetProfileForLevel, wow335HunterPetRakeDamage, wow335HunterPetGrowlProfileForLevel, wow335HunterPetTargetPolicy, WOW335_HUNTER_PET_DEFAULT_COMMAND, WOW335_HUNTER_PET_DEFAULT_REACT_MODE, type Wow335HunterPetCommand, type Wow335HunterPetReactMode, type Wow335HunterPetFamily, type Wow335HunterPetProfile, } from "./wow335HunterPetRuntime"; import { isWow335HunterPetFamily, normalizeWow335HunterPetFamily, wow335HunterPetFamilyName, } from "./wow335HunterPetFamilySelection"; import { wow335HunterPetArmorForProfile, wow335HunterPetBaseDefenseForLevel, wow335HunterPetInheritedDefenses, type Wow335HunterPetOwnerResistances, } from "./wow335HunterPetInheritedDefenseRuntime"; import { WOW335_HUNTER_BESTIAL_DISCIPLINE_TALENT_ID, WOW335_HUNTER_PET_BASE_FOCUS_REGENERATION_PER_SECOND, wow335HunterPetFocusRegenerationPerSecond, } from "./wow335HunterBestialDisciplineRuntime"; import { WOW335_HUNTER_GO_FOR_THE_THROAT_TALENT_ID, wow335HunterGoForTheThroatAttackKindForAbility, wow335HunterGoForTheThroatProcPlan, wow335HunterGoForTheThroatRankMechanics, } from "./wow335HunterGoForTheThroatRuntime"; import { awardExperience, healerManExperienceAward, normalizeProgression, playerCombatPower, xpRequiredForNextLevel, } from "./progression"; import { runtimeAllocateTalentPoint as allocateTalentPoint, runtimeNormalizeTalentRanks as normalizeTalentRanks, runtimeResetTalentRanks as resetTalentRanks, runtimeTalentNodeById as talentNodeById, runtimeTalentPointsForLevel as talentPointsForLevel, type RuntimeTalentRanks as TalentRanks, } from "./talentRuntimeCatalog"; import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; import { WOW335_DEATH_KNIGHT_RIME_READY_TAG, wow335DeathKnightRimeProcSucceeds, } from "./wow335DeathKnightRimeRuntime"; import { wow335DeathKnightReapingApplyProcPlan, wow335DeathKnightReapingProcPlan, wow335DeathKnightReapingRestoreLandedSpend, wow335DeathKnightReapingShouldProc, wow335DeathKnightReapingStateForRunes, wow335DeathKnightSpentRuneSlots, } from "./wow335DeathKnightReapingRuntime"; import { WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID, wow335DeathKnightSuddenDoomTriggerForLandedHit, } from "./wow335DeathKnightSuddenDoomRuntime"; import { WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_SPELL_ID, wow335DeathKnightMarkOfBloodAuraDefinition, wow335DeathKnightMarkOfBloodOnDamage, } from "./wow335DeathKnightMarkOfBloodRuntime"; import { WOW335_DEATH_KNIGHT_WANDERING_PLAGUE_DAMAGE_ABILITY_ID, WOW335_DEATH_KNIGHT_WANDERING_PLAGUE_TALENT_ID, wow335DeathKnightWanderingPlagueIsDiseaseTag, wow335DeathKnightWanderingPlagueMeleeCritBonusPercentForTalentRanks, wow335DeathKnightWanderingPlagueProcForDiseaseTick, } from "./wow335DeathKnightWanderingPlagueRuntime"; import { WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG, WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID, wow335DeathKnightScentOfBloodOnIncomingEvent, wow335DeathKnightScentOfBloodOnMeleeHit, } from "./wow335DeathKnightScentOfBloodRuntime"; import { WOW335_DEATH_KNIGHT_BLOOD_CAKED_BLADE_PROC_TAG, WOW335_DEATH_KNIGHT_BLOOD_CAKED_BLADE_TALENT_ID, WOW335_DEATH_KNIGHT_BLOOD_CAKED_STRIKE_ABILITY_ID, wow335DeathKnightBloodCakedBladeShouldProc, wow335DeathKnightBloodCakedBladeProcChance, wow335DeathKnightBloodCakedStrikeDamageCoefficient, } from "./wow335DeathKnightBloodCakedBladeRuntime"; import { WOW335_DEATH_KNIGHT_BLOOD_GORGED_TALENT_ID, wow335DeathKnightBloodGorgedArmorPenetrationPercent, wow335DeathKnightBloodGorgedDamageBonusPercent, } from "./wow335DeathKnightBloodGorgedRuntime"; import { wow335DeathKnightSpellFamilyInfoForSpellId } from "./wow335DeathKnightBloodGorgedSpellFamily"; 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, type Wow335DeathKnightDancingRuneWeaponOwnerEvent, } from "./wow335DeathKnightDancingRuneWeaponRuntime"; import { WOW335_DEATH_KNIGHT_BLOOD_STRIKE_ABILITY_ID, WOW335_DEATH_KNIGHT_DESOLATION_TALENT_ID, wow335DeathKnightDesolationAuraDefinition, wow335DeathKnightDesolationShouldTrigger, } from "./wow335DeathKnightDesolationRuntime"; import { WOW335_DEATH_KNIGHT_BLOODY_VENGEANCE_TALENT_ID, wow335DeathKnightBloodyVengeanceAuraDefinition, wow335DeathKnightBloodyVengeanceShouldTrigger, } from "./wow335DeathKnightBloodyVengeanceRuntime"; import { WOW335_DEATH_KNIGHT_BLADE_BARRIER_TALENT_ID, wow335DeathKnightBladeBarrierAuraDefinition, wow335DeathKnightBladeBarrierShouldActivate, } from "./wow335DeathKnightBladeBarrierRuntime"; import { WOW335_DEATH_KNIGHT_SPELL_DEFLECTION_TALENT_ID, wow335DeathKnightSpellDeflectionDamage, wow335DeathKnightSpellDeflectionSchoolMask, wow335DeathKnightSpellDeflectionShouldProc, } from "./wow335DeathKnightSpellDeflectionRuntime"; import { applyTalentModifierValue, parseTalentModifiers, talentModifiersForRanks, type TalentModifier, type TalentModifierContext, } from "./talentModifiers"; import { getMobPosition, getMobRuntimeTransform, mobRuntimeDistanceSquared, nearbyMobRuntimeIds, removeMobPosition, setMobPosition, type CombatPosition, } from "./mobRuntimeRegistry"; import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry"; import { raceAffixHasteMultiplier, raceAffixMobDamageMultiplier } from "./dungeonRaceAffixBuffs"; import { wow335LightwellDamageCancellationFraction, wow335LightwellRenewProfileForCreature, } from "./wow335PriestLightwellRuntime"; import { WOW335_PRIEST_FOCUSED_WILL_TALENT_ID, wow335PriestFocusedWillAuraDefinition, wow335PriestFocusedWillShouldProc, } from "./wow335PriestFocusedWillRuntime"; import { WOW335_ROGUE_IMPROVED_KICK_TALENT_ID, WOW335_ROGUE_UNFAIR_ADVANTAGE_COOLDOWN_TAG, WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID, wow335RogueImprovedKickAuraEffectForRank, wow335RogueImprovedKickSilenceDurationMs, wow335RogueUnfairAdvantageDamageMultiplierForRank, } from "./wow335RogueTalentRuntime"; import { beastWithinRankMechanics, consumeLockAndLoadCharge, entrapmentRankMechanics, freezingTrapDurationMs, lockAndLoadRankMechanics, WOW335_HUNTER_ENTRAPMENT_TALENT_ID, WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG, WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG, } from "./wow335HunterRuntime"; import { wow335PaladinAuraMasteryAurasForEntity, wow335PaladinAuraMasterySelfControlDuration, isWow335PaladinRetributionAuraDamageShield, wow335PaladinRetributionAuraWithSpellPower, } from "./wow335PaladinAuraMasteryRuntime"; import { WOW335_PALADIN_ILLUMINATION_TALENT_ID, wow335PaladinIlluminationRefundPlan, } from "./wow335PaladinIlluminationRuntime"; import { WOW335_MAGE_FROST_WARDING_TALENT_ID, WOW335_MAGE_FROST_WARDING_WARD_ABILITIES, wow335MageFrostWardingArmorAuraEffect, wow335MageFrostWardingProcPlan, } from "./wow335MageFrostWardingRuntime"; import { WOW335_PALADIN_VENGEANCE_AURA_TAG, WOW335_PALADIN_VENGEANCE_TALENT_ID, wow335PaladinVengeanceAuraDefinition, wow335PaladinVengeanceDamageBonusPercent, wow335PaladinVengeanceProcPlan, } from "./wow335PaladinVengeanceRuntime"; import { WOW335_PALADIN_CONCENTRATION_AURA_EFFECT_ID, WOW335_PALADIN_IMPROVED_CONCENTRATION_AURA_TALENT_ID, wow335PaladinConcentrationAuraPushbackReduction, } from "./wow335PaladinConcentrationAuraRuntime"; import { WOW335_DRUID_FERAL_INSTINCT_ATTACK_NAMES, wow335DruidFeralInstinctEnrageAura, wow335DruidFeralInstinctTigerAura, } from "./wow335DruidTalentRuntime"; import { WOW335_DRUID_MASTER_SHAPESHIFTER_TALENT_ID, wow335DruidMasterShapeshifterModifiersForForm, wow335DruidShapeshiftFormForAuras, } from "./wow335DruidMasterShapeshifterRuntime"; import { WOW335_DRUID_PREDATORY_INSTINCTS_TALENT_ID, wow335DruidPredatoryInstinctsAreaDamageMultiplier, wow335DruidPredatoryInstinctsCriticalDamageMultiplier, } from "./wow335DruidPredatoryInstinctsRuntime"; import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog"; import { WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG, WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG, warlockMetamorphosisCanCastAbilityWhileActive, warlockMetamorphosisFormActionReady, wow335WarlockImmolationAttackPowerReduction, wow335WarlockMetamorphosisControlDuration, } from "./wow335WarlockMetamorphosisRuntime"; import { WARLOCK_BACKDRAFT_READY_TAG, warlockAftermathDazeForRank, warlockBackdraftDestructionSpellEligible, warlockBackdraftEffectsForRank, warlockBackdraftTriggerEligible, } from "./wow335WarlockTalentRuntime"; import { getPartyRuntimePosition, partyRuntimeDistanceSquared, requestPartyRuntimeCombatRelocation, updatePartyRuntimePosition, } from "./partyRuntimeRegistry"; import { usePartyStore } from "./partyStore"; import { advanceWow335KillingSpreeAssault, createWow335KillingSpreeWindow, selectWow335KillingSpreeTarget, WOW335_ROGUE_KILLING_SPREE_DURATION_MS, type Wow335KillingSpreeWindow, type Wow335KillingSpreeWeaponSnapshot, } from "./wow335RogueKillingSpreeRuntime"; import { actionBindingId, defaultActionBindings, normalizeActionBindings, resetActionBindingLayer as restoreActionBindingLayer, withActionBinding, type ActionBindingControl, type ActionBindingLayer, type ActionBindings, } from "./actionBindings"; import type { CharacterCombatAnimationEvent, CharacterCombatAnimationKind, } from "./combatAnimation"; import { absorbDamage as absorbAuraDamage, applyAura as applyCombatAura, dispelAuras as dispelCombatAuras, evaluateAuraProcs, expireAuras, resolveDamageValue as resolveAuraDamageValue, resolveHealingValue as resolveAuraHealingValue, resolveResourceValue as resolveAuraResourceValue, resolveStatValue as resolveAuraStatValue, type ActiveAura, type AbsorbConsumption, type AuraDefinition, type AuraModifier, type DispelCategory, type ProcEvent, type TriggeredAuraProc, } from "./combatAuras"; import { rollBossLoot } from "./lootCatalog"; import { bossCardForSource } from "./bossCardCatalog"; import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards"; import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula"; import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts"; import { inventoryItemUsableForCategory, normalizeInventory, type InventoryItem } from "./lootTypes"; import { PLAYER_EQUIPMENT_OWNER_ID, activeEquipmentStatsForClass, equipInventoryItem, normalizeEquipment, unequipInventorySlot, type EquipmentAssignments, type EquipmentSlot, } from "./equipment"; import { resolvedEquipmentLoadout, resolvedEquipmentStats, type HeirloomContext, } from "./heirloomEquipment"; import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romAbilityNeedsTwoHand, romAbilityRequiredTargetStateTag, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount, romTargetHasSlashBleed, romWarriorAbilityAdjustment, romWarriorPhysicalWeaponTooltip, romWarriorThreatMultiplier } from "./romCombatMechanics"; import { ROM_LOCAL_ACTIVE_BLOCKING_STANCE_AURA_ID, romCurrentCoaBlockingStanceModifiers, romCurrentCoaResistModifiers, romCurrentCoaThreatFactors, } from "./romWarriorCurrentCoaMechanics"; import { romWarriorDruidWhirlwindAdjustment } from "./romWarriorDruidRageOfCalmness"; import { ROM_COA_LEOPARD_INSTINCT_ID, createRomLeopardInstinctState, romLeopardInstinctOnDodge, romLeopardInstinctOnHitReceived, } from "./romLeopardInstinctOverrides"; import { ROM_COA_WARRIOR_ROGUE_EXHAUSTION_ID, ROM_COA_WARRIOR_ROGUE_PROBING_ATTACK_ID, romCoaWarriorRogueExhaustionProc, } from "./romWarriorRogueExhaustion"; import { ROM_ROGUE_POISON_AURA_TAG, ROM_ROGUE_POISON_ATTACK_SPEED_TAG, ROM_ROGUE_POISON_SKILL_ID, romRoguePoisonAttackResultForAttack, romRoguePoisonAttackSpeedTargetId, } from "./romRoguePoisonRuntime"; import { ROM_ROGUE_ENERGIZED_HANDS_COOLDOWN_REDUCTION_MS, romRogueEnergizedHandsActive, romRogueEnergyMaximumAfterAuraTransition, } from "./romRogueEnergizedHandsRuntime"; import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, ROM_MAGE_WARLOCK_PASSIVE_TAGS, romFireLightningBurstReset, romMageWarlockCriticalEffects, } from "./romMageWarlockOverrides"; import { ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_EFFECT, ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_EFFECT, ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID, romMageWarriorMeditationPathAllowsMovement, romMageWarriorMeditationPathCanProc, romMageWarriorMeditationPathHasState, } from "./romMageWarriorRuntime"; import { ROM_MAGE_KNIGHT_LIGHT_OF_DAY, ROM_MAGE_KNIGHT_LIGHT_OF_DAY_COOLDOWN_EFFECT, ROM_MAGE_MEDITATIVE_CURRENT, ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT, romMageCooldownReductionMs, romMageKnightLightOfDayProc, romMageKnightLightOfDayReadyAt, romMageMeditativeCurrentProc, romMageMeditativeCurrentReadyAt, } from "./romMageRuntime"; import { ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG, isRomMageDruidPair, romMageDruidGreenGuardianAdvanceStationaryProgress, romMageDruidGreenGuardianMovementSpeedMultiplier, romMageDruidGreenGuardianStacksAfterStationarySecond, type RomMageDruidGreenGuardianStationaryProgress, } from "./romMageDruidGreenGuardianRuntime"; import { ROM_SCOUT_PRIEST_DRUMS_OF_WAR, ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA, ROM_SCOUT_PRIEST_DRUMS_OF_WAR_CRITICAL_TAG, romScoutPriestDrumsOfWarCriticalMultiplier, romScoutPriestDrumsOfWarProc, } from "./romScoutPriestOverrides"; import { ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY, ROM_SCOUT_PRIEST_SNIPER_TAG, romRogueMageDayOfRainBaseMissPercent, romScoutRogueProcActionSpec, romScoutRogueWhirlwindBonusEffect, } from "./romScoutRogueOverrides"; import { ROM_ROGUE_DAGGER_MASTERY_TAG, ROM_ROGUE_INSTANT_SHADOW_READY_TAG, ROM_ROGUE_OFF_HAND_MASTERY_ACCURACY_PERCENT, ROM_ROGUE_OFF_HAND_MASTERY_DAMAGE_PERCENT, ROM_ROGUE_OFF_HAND_MASTERY_TAG, ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID, ROM_ROGUE_TREASURE_HUNTER_TAG, ROM_ROGUE_COERCE_PASSIVE_SPELL_ID, romRogueCoerceAbilityAdjustment, romRogueDaggerDamageMultiplier, romRogueShadowWalkerAdjustment, romRogueTreasureHunterLootChance, } from "./romRogueMechanics"; import { ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID, romRogueChaosDaggerDamage, } from "./romRogueChaosDagger"; import { ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID, romRogueChampionNerveGearEffect, romRogueChampionNerveGearShouldTrigger, } from "./romRogueChampionNerveGearRuntime"; import { ROM_PRIEST_CHAMPION_REGENERATE_PROC_ACTION_ID, ROM_PRIEST_CHAMPION_PROTECTION_RUNE_SEAL_SPELL_ID, ROM_PRIEST_CHAMPION_HEART_RUNE_ENERGY_SPELL_ID, romPriestChampionProtectionRuneSealProcIsEligible, romPriestChampionCooldownOverride, romPriestChampionRageControlGain, romPriestVowOfSilenceDurationMs, } from "./romPriestChampionOverrides"; import { ROM_PRIEST_HOLY_BLESSING_TAG, romPriestHolyBlessingStats, romPriestHolyBlessingWisdom, } from "./romPriestHolyBlessingRuntime"; import { ROM_DRUID_MAGE_INSPIRATION_AURA_TAG, ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID, isRomDruidMagePair, romDruidMageInspirationApplySpendStack, romDruidMageInspirationHastePercent, romDruidMageInspirationRecipientIds, } from "./romDruidMageInspirationRuntime"; import { romPriestScoutSaintsBlessingDamageMultiplier } from "./romPriestScoutSaintsBlessingDamage"; import { romPriestHolyCandleIsActive, romPriestHolyCandleSelfDamageAmount, } from "./romPriestHolyCandleRuntime"; import { ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_ID, ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG, resolveRomPriestChampionBeaconFromAuras, } from "./romPriestChampionBeaconRuntime"; import { ROM_PRIEST_KNIGHT_HOLY_POWER_HEAL_USES_TAG, romPriestKnightConditionalCastEffects, romPriestWarriorStanceUseAllowed, } from "./romPriestKnightOverrides"; import { romKnightCompassionWhirlwindShieldCooldownReductionMs, ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC, ROM_KNIGHT_MASTER_OF_PARRY_TAG, ROM_KNIGHT_SHIELD_OF_VALOR_TAG, romKnightGodsVengeanceShouldResetShadowStep, romKnightShieldOfValorCooldownReductionMs, romKnightWarriorWhirlwindAdjustment, } from "./romKnightOverrides"; import { ROM_KNIGHT_PANTHERS_BLESSING_ABILITY_ID, romKnightPanthersBlessingRestoreOnDodge, } from "./romKnightPanthersBlessingRuntime"; import { hasOwnedRomScoutBloodArrow, isRomBloodArrowOwner, isRomScoutBloodArrowAbility, romScoutRogueBloodArrowRangedAutoAttackCost, romBloodArrowCastCosts, ROM_SCOUT_BLOOD_ARROW_ABILITY_ID, ROM_SCOUT_BLOOD_ARROW_AURA_TAG, ROM_SCOUT_BLOOD_ARROW_DEACTIVATION_COOLDOWN_MS, } from "./romBloodArrow"; import { isRomScoutAutoShotAbility, isRomScoutRogueAutoShotOwner, ROM_SCOUT_AUTO_SHOT_DAMAGE_COEFFICIENT, ROM_SCOUT_AUTO_SHOT_MAX_RANGE, } from "./romScoutAutoShot"; import { ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID, romCoaKnightRogueGodsSwordBonusLightDamage, } from "./romKnightGodsSwordCoaMechanics"; import { ROM_DRUID_GREEN_GUARDIAN_TAG, ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG, romDruidPeriodicHealAmount, } from "./romDruidOverrides"; import { ROM_CHAMPION_FORGE_STACK_AURA, ROM_CHAMPION_RUNE_OVERLOAD_RAGE_PER_SECOND, ROM_CHAMPION_RUNE_OVERLOAD_TAG, ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID, ROM_CHAMPION_SOUL_FORGE_PROFILE, ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID, ROM_CHAMPION_WAY_OF_FRENZY_PROFILE, } from "./romChampionOverrides"; import { ROM_CHAMPION_CHAIN_DRIVE_WINDOW_AURA, ROM_CHAMPION_FORGE_RANK_TWO_AURA_ID, ROM_CHAMPION_FORGE_RANK_TWO_PROC_ACTION_ID, ROM_CHAMPION_FORGE_SOURCE_SKILL_ID, ROM_CHAMPION_FORGE_SHIELD_FORM_COMPONENT_ID, romChampionForgeProcSucceeds, romChampionHasForgeRankTwoAura, romChampionHasSourceComponentAura, romChampionRunePulseUseEffect, } from "./romChampionForgeRuntime"; import { ROM_CHAMPION_RUNE_SIPHON_ID, romChampionDarkEnergyStrikeGroup0Survives, romChampionDarkEnergyStrikeMatchesBuild, romChampionDarkEnergyStrikePacketGroupAtDamageIndex, romChampionDarkEnergyStrikeShouldResetRuneSiphon, } from "./romChampionDarkEnergyStrikeRuntime"; import { WOW335_WARLOCK_SOUL_LINK_TAG, warlockSoulLinkDamageSplit, warlockSoulLinkDemon, warlockSoulLinkSharePercent, } from "./wow335WarlockSoulLinkRuntime"; import { ROM_WARDEN_BRIAR_SHIELD_TAG, ROM_WARDEN_DAMAGE_TRANSFER_FRACTION, ROM_WARDEN_DAMAGE_TRANSFER_TAG, ROM_WARDEN_WILL_ATTACK_TAG, romWardenBriarShieldReflectDamage, romWardenBriarShieldShouldReflect, } from "./romWardenOverrides"; import { ROM_WARDEN_REVIVAL_CHARGE_AURA, ROM_WARDEN_REVIVAL_DODGE_AURA, romWardenRevivalBuildMatches, romWardenRevivalNormalHeal, romWardenRevivalShouldTriggerOnIncomingHit, romWardenRevivalStateIsActive, } from "./romWardenRevivalRuntime"; import { romWarlockStateRefreshTags } from "./romWarlockOverrides"; import { ROM_WARLOCK_MAGE_SOUL_BRAND, romWarlockMageSoulBrandEffects, } from "./romWarlockMageSoulBrand"; import { romWarlockMageSoulBrandStingHasWillpowerBladeState, } from "./romWarlockMageSoulBrandSting"; import { ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_DAMAGE_BONUS, ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG, } from "./romWarlockPriestSpreadingPain"; import { ROM_WARRIOR_BRIAR_TECHNIQUE_AURA, ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG, ROM_WARRIOR_BRIAR_TECHNIQUE_TRIGGER_SPELL_IDS, } from "./romWarriorOverrides"; import { romCreatureFamilyForServerEntry, romCreatureFamilyTargetTags, type CombatCreatureFamily } from "./romCreatureFamilies"; import { WOW335_PALADIN_BLESSED_LIFE_TALENT_ID, wow335PaladinBlessedLifeDamageMultiplierForRoll, wow335PaladinRedoubtBuff, wow335PaladinRedoubtProfile, wow335PaladinVindicationProfile, } from "./wow335PaladinTalentRuntime"; import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles"; import { specializationById } from "./specializationCatalog"; import { EMPTY_ITEM_STATS, armorReductionPercent, damageAfterArmor, itemStatSummary, weaponDps, type DamageSchool, type ItemStats, type WeaponStats, } from "./itemStats"; import { baseResourcesFor, damageAfterEquipmentMitigation, deriveCharacterStats, equipmentAttackPower, hastedDuration, healthFromTotalStamina, ratingPercent, type CharacterStats, type WotlkAttackKind, } from "./wotlkStats"; import { healingRecipientIds } from "./healingAbilityRules"; import { ROGUE_DEADLY_POISON_AURA, ROGUE_DEADLY_POISON_COATING_TAG, ROGUE_DEADLY_POISON_DOSE_TAG, ROGUE_ENVENOM_BUFF_TAG, ROGUE_INSTANT_POISON_TAG, hasActiveAuraTag, matchingAuraStacks, rogueDeadlyPoisonTickDamage, rogueEnvenomPreservesDeadlyPoison, rogueInstantPoisonDamage, roguePoisonApplicationChances, } from "./roguePoison"; import { hunterExplosiveTrapDirectScaling, wow335HunterPreventionTypeForAbility } from "./wow335AbilityCatalog"; import { wow335ShamanImprovedWaterShieldCappedRestore, wow335ShamanImprovedWaterShieldShouldProc, wow335ShamanWaterShieldOrbManaAtLevel, } from "./wow335ShamanImprovedWaterShieldRuntime"; import { WOW335_SHAMAN_IMPROVED_EARTH_SHIELD_TIDAL_WAVES_COOLDOWN_MS, WOW335_SHAMAN_IMPROVED_EARTH_SHIELD_TIDAL_WAVES_ICD_TAG, wow335ShamanImprovedEarthShieldHealingAmount, wow335ShamanImprovedEarthShieldProcCharges, wow335ShamanImprovedEarthShieldShouldGrantTidalWaves, } from "./wow335ShamanImprovedEarthShieldRuntime"; import { wow335ShamanImprovedShieldOrbAmount } from "./wow335ShamanImprovedShieldsRuntime"; import { WOW335_SHAMAN_NATURES_GUARDIAN_TALENT_ID, wow335ShamanNaturesGuardianApplyProc, wow335ShamanNaturesGuardianConsumeNextCrit, wow335ShamanNaturesGuardianNextCritIsReady, } from "./wow335ShamanNaturesGuardianRuntime"; import { WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG, WOW335_SHAMAN_TIDAL_WAVES_DURATION_MS, WOW335_SHAMAN_TIDAL_WAVES_MAX_CHARGES, wow335ShamanTidalWavesBonusHealingPercent, wow335ShamanTidalWavesCastTimeMultiplier, wow335ShamanTidalWavesChargesForTrigger, wow335ShamanTidalWavesCriticalChanceBonusPercent, wow335ShamanTidalWavesProcChance, } from "./wow335ShamanTidalWavesRuntime"; import { WOW335_SHAMAN_ELEMENTAL_DEVASTATION_AURA_TAG, wow335ShamanElementalDevastationProfileForRank, } from "./wow335ShamanTalentRuntime"; import { WOW335_SHAMAN_ELEMENTAL_WARDING_TALENT_ID } from "./wow335ShamanElementalWardingRuntime"; import { WOW335_SHAMAN_LIGHTNING_OVERLOAD_PROC_TAG, WOW335_SHAMAN_LIGHTNING_OVERLOAD_TALENT_ID, wow335ShamanLightningOverloadDamageAmount, wow335ShamanLightningOverloadProfileForRank, wow335ShamanLightningOverloadShouldProc, } from "./wow335ShamanLightningOverloadRuntime"; import { WOW335_SHAMAN_FLURRY_TALENT_ID, wow335ShamanFlurryApplyProc, wow335ShamanFlurryConsumeSwingCharge, wow335ShamanFlurryIsActive, wow335ShamanFlurryShouldProc, } from "./wow335ShamanFlurryRuntime"; import { WOW335_MAGE_ARCANE_CONCENTRATION_AURA, WOW335_MAGE_ARCANE_CONCENTRATION_TAG, WOW335_MAGE_BRAIN_FREEZE_AURA, WOW335_MAGE_BRAIN_FREEZE_CONSUMER_SPELLS, WOW335_MAGE_BRAIN_FREEZE_TAG, WOW335_MAGE_BRAIN_FREEZE_TRIGGER_SPELLS, WOW335_MAGE_FINGERS_OF_FROST_AURA, WOW335_MAGE_FINGERS_OF_FROST_TAG, WOW335_MAGE_FOCUS_MAGIC_PROC_AURA, WOW335_MAGE_HOT_STREAK_AURA, WOW335_MAGE_HOT_STREAK_TAG, WOW335_MAGE_IMPROVED_SCORCH_AURA, WOW335_MAGE_IMPROVED_SCORCH_TAG, WOW335_MAGE_IMPACT_AURA, WOW335_MAGE_IMPACT_TAG, } from "./wow335MageTalentRuntime"; import { WOW335_MAGE_NETHERWIND_PRESENCE_TALENT_ID, wow335MageNetherwindPresenceAurasAfterCast, wow335MageNetherwindPresenceHastePercent, } from "./wow335MageNetherwindPresenceRuntime"; import { WOW335_MAGE_FROSTBITE_TALENT_ID, wow335MageFrostbiteProcDurationMs, } from "./wow335MageFrostbiteRuntime"; import { wow335MageShatterCriticalChanceBonusPercent } from "./wow335MageShatterRuntime"; import { WOW335_MAGE_WINTERS_CHILL_AURA, WOW335_MAGE_WINTERS_CHILL_TAG, wow335MageWintersChillProfileForRank, wow335MageWintersChillShouldProc, wow335MageWintersChillTargetModifiers, } from "./wow335MageWintersChillRuntime"; import { WOW335_MAGE_FLAME_THROWING_TALENT_ID, wow335MageFlameThrowingProfile, } from "./wow335MageFlameThrowingRuntime"; import { WOW335_MAGE_ARCANE_POTENCY_AURA, WOW335_MAGE_ARCANE_POTENCY_TAG, WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID, wow335MageArcanePotencyCanProc, wow335MageArcanePotencyCastProfile, wow335MageMasterOfElementsManaRefund, } from "./wow335MageConditionalTalentRuntime"; import { WOW335_MAGE_EMPOWERED_FIRE_TALENT_ID, wow335MageEmpoweredFireEffect, } from "./wow335MageEmpoweredFireRuntime"; import { WOW335_MAGE_MISSILE_BARRAGE_AURA, WOW335_MAGE_MISSILE_BARRAGE_TAG, wow335MageMissileBarrageConsumerEligible, wow335MageMissileBarrageProfileForRank, wow335MageMissileBarrageReachableProcChancePercentForSpell, } from "./wow335MageMissileBarrageRuntime"; 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 { warlockDeathGraspHitChanceBonusForRank, warlockDeathGraspSpellHasteModifier, WARLOCK_NIGHTFALL_COOLDOWN_TAG, WARLOCK_NIGHTFALL_READY_TAG, warlockNightfallConsumerEligible, warlockNightfallDamageMultiplier, warlockNightfallEffectsForRank, warlockNightfallTriggerEligible, } from "./wow335WarlockTalentRuntime"; import { WOW335_WARRIOR_BLOOD_CRAZE_COOLDOWN_KEY, WOW335_WARRIOR_BLOOD_CRAZE_INTERNAL_COOLDOWN_MS, WOW335_WARRIOR_BLOOD_CRAZE_TALENT_ID, WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY, WOW335_WARRIOR_SECOND_WIND_TALENT_ID, wow335WarriorBloodCrazeProfile, wow335WarriorSecondWindProfile, } from "./wow335WarriorTalentRuntime"; import { WOW335_WARRIOR_DAMAGE_SHIELD_ABILITY_ID, WOW335_WARRIOR_DAMAGE_SHIELD_TALENT_ID, wow335WarriorDamageShieldReflectDamage, } from "./wow335WarriorDamageShieldRuntime"; import { WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_ABILITY_ID, WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_COOLDOWN_TAG, WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_READY_TAG, WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_TALENT_ID, WOW335_WARRIOR_VICTORY_RUSH_KILL_READY_TAG, wow335WarriorImprovedVictoryRushCooldownAuraDefinition, wow335WarriorImprovedVictoryRushCriticalChanceBonusPercent, wow335WarriorImprovedVictoryRushReadyAuraDefinition, wow335WarriorImprovedVictoryRushShouldProc, wow335WarriorImprovedVictoryRushThreatScale, wow335WarriorVictoryRushKillAuraDefinition, } from "./wow335WarriorVictoryRushRuntime"; import { WOW335_WARRIOR_IMPROVED_DEFENSIVE_STANCE_TALENT_ID, wow335WarriorImprovedDefensiveStanceAdditiveIncomingDamage, wow335WarriorImprovedDefensiveStanceEnrageAuraDefinition, wow335WarriorImprovedDefensiveStanceShouldProc, wow335WarriorHasDefensiveStance, type Wow335WarriorAvoidanceTrigger, } from "./wow335WarriorImprovedDefensiveStanceRuntime"; import { WOW335_PALADIN_TOUCHED_BY_THE_LIGHT_TALENT_ID, WOW335_WARRIOR_CRITICAL_BLOCK_TALENT_ID, wow335WarriorCriticalBlockDamage, } from "./wow335WarriorCriticalBlockRuntime"; import { WOW335_WARRIOR_ANGER_MANAGEMENT_CHARGE_AURA_TAG, WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID, wow335WarriorAngerManagementProfile, wow335WarriorChargeRageProfile, wow335WarriorChargeRank, } from "./wow335WarriorAngerManagementRuntime"; import { WOW335_WARRIOR_UNBRIDLED_WRATH_ABILITY_ID, WOW335_WARRIOR_UNBRIDLED_WRATH_TALENT_ID, wow335WarriorUnbridledWrathProfile, wow335WarriorUnbridledWrathShouldProc, } from "./wow335WarriorUnbridledWrathRuntime"; import { WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG, WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS, WOW335_SHAMAN_MAELSTROM_WEAPON_ELIGIBLE_SPELLS, WOW335_SHAMAN_MAELSTROM_WEAPON_MAX_STACKS, wow335ShamanMaelstromWeaponCastTimeReductionPercent, wow335ShamanMaelstromWeaponPvpDamageReductionPercent, wow335ShamanMaelstromWeaponProfile, } from "./wow335ShamanMaelstromWeaponRuntime"; import { baseMissChancePercent, combatAttackerFromCharacterStats, combatDefenderFromCharacterStats, deflectLandedCombatResult, resolveCombatResult, type CombatAttackerStats, type CombatDefenderStats, type CombatResult, type CombatResultRules, type CombatRollStage, } from "./combatResults"; import { DEFAULT_ENEMY_ATTACK, enemyControlPreventsActions, maximumEnemyAttackRange, normalizeEnemyAttacks, type EnemyAttackAnimation, type EnemyAttackDefinition, type EnemyControlMechanic, type EnemyDamageSchool, type EnemyMeleeAutoCriticalProc, } from "./enemyAttacks"; import { creatureRuntimeBaseStats, creatureTemplateReference, type AzerothCoreMechanic, type AzerothCoreSchool, } from "./azerothCoreReference"; import { dispatchDungeonEncounterSignal, dungeonEncounterStateMatches, } from "./dungeonEncounterRuntime"; import { PLAYER_AGGRO_ID, addActorThreat, pruneThreatTable, resolveAggroTarget, seedActorThreat, tauntThreat, threatFromDamage, threatFromHealing, type AggroActorId, type ForcedTarget, type ThreatSource, type ThreatTable, } from "./aggro"; import { distanceSquared, planarDistanceSquared } from "./mobAi"; import { onlinePlayerRuntimeActor, onlinePlayerRuntimeActors, } from "./onlineSessionRuntime"; import { abilityIsPrecombatPreparation, precombatPreparationEffects, rotationProfileFor, } from "./combatRotation"; import { emitCombatPresentation, penancePulseVisualSpellId, presentationDeliveryForAbility, presentationSchoolForAbility, presentationSourceForAbility, presentationSourceForSpellId, resetCombatPresentationSession, type CombatPresentationPhase, } from "./combatPresentation"; /** Stable target id used by named player auras and HUD consumers. */ export const PLAYER_AURA_ENTITY_ID = PLAYER_AGGRO_ID; const WEAKENED_SOUL_AURA: AuraDefinition = { id: "wow335:priest:weakened-soul", name: "Weakened Soul", disposition: "debuff", durationMs: 30_000, maxStacks: 1, stackBehavior: "refresh", dispelCategory: "magic", }; function hasWeakenedSoul(auras: readonly ActiveAura[], targetId: string, now: number): boolean { return auras.some((aura) => aura.targetId === targetId && aura.definition.id === WEAKENED_SOUL_AURA.id && (aura.expiresAt === null || aura.expiresAt > now)); } function consumableEnrage(aura: ActiveAura, targetId: string, now: number): boolean { return aura.targetId === targetId && aura.definition.disposition === "buff" && aura.definition.dispelCategory === "enrage" && !aura.definition.name.startsWith("Enraged Regeneration") && (aura.expiresAt === null || aura.expiresAt > now); } import { DEFAULT_THREAT_METER_POSITION, normalizeHudPosition, type NormalizedHudPosition, } from "./hudLayout"; import { DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT, DEFAULT_PREVIEW_FRAME_RATE_LIMIT, normalizeGameplayFrameRateLimit, normalizePreviewFrameRateLimit, type GameplayFrameRateLimit, type PreviewFrameRateLimit, } from "./frameRateSettings"; export type MinimapRotation = "north-up" | "player-up"; export interface GameplaySettings extends GraphicsSettings { readonly showMobHealthBars: boolean; readonly showMobAggroRanges: boolean; readonly showTargetFrame: boolean; readonly showThreatMeter: boolean; readonly threatMeterPosition: NormalizedHudPosition; readonly showFloatingCombatText: boolean; readonly showOwnDamageText: boolean; readonly showAllyDamageText: boolean; readonly showIncomingDamageText: boolean; readonly healerManaRecovery: boolean; readonly autoTargetNearest: boolean; readonly enableScreenShake: boolean; readonly masterVolume: number; readonly uiScale: number; readonly minimapRotation: MinimapRotation; readonly gameplayFrameRate: GameplayFrameRateLimit; readonly previewFrameRate: PreviewFrameRateLimit; } export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({ ...DEFAULT_GRAPHICS_SETTINGS, showMobHealthBars: true, showMobAggroRanges: false, showTargetFrame: true, showThreatMeter: false, threatMeterPosition: DEFAULT_THREAT_METER_POSITION, showFloatingCombatText: true, showOwnDamageText: true, showAllyDamageText: false, showIncomingDamageText: false, healerManaRecovery: true, autoTargetNearest: true, enableScreenShake: true, masterVolume: 0, uiScale: 1, minimapRotation: "player-up", gameplayFrameRate: DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT, previewFrameRate: DEFAULT_PREVIEW_FRAME_RATE_LIMIT, }); /** Prevents a burst of party hits from restarting the same wound clip every frame. */ export const MOB_WOUND_ANIMATION_COOLDOWN_MS = 650; /** Empty corpses remain visible long enough to read the death, then clean themselves up. */ export const EMPTY_CORPSE_DESPAWN_MS = 10_000; /** Looting cannot hide a corpse before its one-shot death animation has had time to land. */ export const MINIMUM_CORPSE_VISIBLE_MS = 1_500; /** Default proximity pull radii for dungeon enemies, expressed in world units. */ export const DEFAULT_MOB_AGGRO_RANGE = 16; export const DEFAULT_BOSS_AGGRO_RANGE = 20; /** Safety ceiling for synchronous proc-to-triggered-spell chains. */ const MAX_TRIGGERED_SPELL_CHAIN_DEPTH = 16; export interface MobStatus { readonly kind: MobStatusKind; readonly sourceAbilityId: string; readonly sourceActorId?: string; readonly endsAt: number; readonly magnitude?: number; readonly breakOnDamage?: boolean; } export interface MobAura { readonly id: string; readonly sourceAbilityId: string; readonly sourceActorId?: AggroActorId; readonly endsAt: number; readonly magnitude?: number; readonly lifeStealPercent?: number; /** Persistent driver for a content-authored main-hand auto-attack proc. */ readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc; /** A charged modifier consumed by a successful main-hand auto attack. */ readonly nextMeleeCriticalChancePercent?: number; readonly charges?: number; } export interface MobMagicResistances { readonly arcane: number; readonly fire: number; readonly frost: number; readonly nature: number; readonly shadow: number; } /** Public enemy cast state used by combat logic now and cast-bar UI later. */ export interface EnemyActiveCast { readonly attackId: string; readonly name: string; readonly spellId?: number; readonly school: EnemyDamageSchool; readonly interruptible: boolean; readonly targetActorId: AggroActorId | null; readonly sourcePosition: CombatPosition; readonly targetPosition: CombatPosition; readonly sourceYaw: number; readonly startedAt: number; readonly completesAt: number; } export interface DiminishingReturnState { readonly applications: number; readonly resetAt: number; } /** Shared state for the already-integrated creature offhand/Thrash runtime. */ interface EnemyWeaponAttackRuntimeState { readonly dualWieldEnabled?: boolean; readonly nextEnemyOffhandAttackAt?: number; readonly pendingExtraAttacks?: readonly QueuedEnemyThrashExtraAttacks[]; readonly lastDamagedActorId?: AggroActorId | null; } export interface MobCombatState extends EnemyWeaponAttackRuntimeState { /** When the hostile actor is a player, preserve its actual defense and resources. */ readonly characterDefender?: CombatDefenderStats; readonly characterShield?: number; readonly characterResources?: Readonly>>; readonly id: string; readonly name: string; readonly serverEntry: number | null; readonly sourceCreatureGuid?: number; readonly sourceEquipment?: EnemySpawnEquipmentContract; readonly runewakerNpcId?: number; readonly creatureFamily: CombatCreatureFamily; readonly summonedBy?: string; readonly spawnGroupId?: string; readonly boss: boolean; readonly lootSourceId: string | null; readonly level: number; readonly armor: number; readonly maxMana: number; readonly attackPower: number; readonly attackPowerDamageContribution: number; readonly mechanicImmunities: readonly AzerothCoreMechanic[]; readonly schoolImmunities: readonly AzerothCoreSchool[]; readonly resistances: MobMagicResistances; readonly health: number; readonly maxHealth: number; readonly healthMultiplier: number; readonly damageMultiplier: number; /** Simulator point-damage budget; source percentage-of-HP damage bypasses it. */ readonly nativeDamageBudgetScale?: number; readonly bonusLootChance: number; readonly dead: boolean; readonly hasLoot: boolean; readonly pendingLoot: readonly InventoryItem[]; readonly bossCardDrop?: BossCardDrop; readonly looted: boolean; readonly despawned: boolean; readonly diedAt: number | null; readonly corpseDespawnAt: number | null; readonly xpReward: number; readonly experienceGranted: boolean; readonly statuses: readonly MobStatus[]; readonly controlDiminishingReturns?: Readonly>>; readonly tauntDiminishingReturns?: DiminishingReturnState; readonly auras: readonly MobAura[]; readonly threatByActor: ThreatTable; readonly targetActorId: AggroActorId | null; readonly forcedTarget: ForcedTarget | null; readonly lastDamageAt: number; readonly engaged: boolean; readonly combatPhase: "idle" | "chasing" | "attacking" | "returning"; readonly navigationEvadeUntil?: number; readonly homePosition: CombatPosition | null; readonly nextAttackAt: number; readonly abilityReadyAt: Readonly>; /** Last sourced melee-auto interval factor, for preserving an in-flight swing. */ readonly meleeAttackSpeedMultiplier?: number; readonly abilityCastCounts: Readonly>; readonly activeCast: EnemyActiveCast | null; readonly schoolLockedUntil: Readonly>>; readonly attackRange: number; readonly attackDamage: number; readonly swingMs: number; readonly aggroRange: number; readonly leashRange: number; readonly moveSpeed: number; readonly attackRevision: number; readonly attackAnimation: EnemyAttackAnimation; readonly attacks: readonly EnemyAttackDefinition[]; readonly nextAttackIndex: number; readonly lastAttackId: string | null; readonly woundRevision: number; readonly nextWoundAnimationAt: number; readonly deathRevision: number; } export interface ActiveCast { readonly id: number; readonly abilityId: string; readonly targetId: string | null; readonly mode: "cast" | "channel"; readonly startedAt: number; readonly completesAt: number; readonly originalDurationMs: number; readonly pushbackHits: number; readonly school: DamageSchool; readonly canCastWhileMoving: boolean; readonly origin: CombatPosition; readonly groundPosition: CombatPosition | null; readonly totalTicks: number; readonly ticksCompleted: number; readonly nextTickAt: number; /** Tidal Waves charges consumed at cast start, snapshotted for its crit rider. */ readonly tidalWavesChargesConsumed?: number; /** Maelstrom Weapon's rank and stacks consumed at cast start, retained for its PvP damage rider. */ readonly maelstromWeaponRank?: number; readonly maelstromWeaponStacksConsumed?: number; /** DBC-derived pre-discount base Mana cost retained for Illumination at cast completion. */ readonly illuminationBaseManaCost?: number; /** Arcane Potency's next-spell critical chance, snapshotted when a cast starts. */ readonly arcanePotencyCriticalChanceBonusPercent?: number; /** Mana actually committed for this cast, used by Master of Elements refunds. */ readonly manaSpent?: number; /** Holy Candle has already charged during this channel; do not charge later pulses. */ readonly romPriestHolyCandleCharged?: boolean; } export interface TimedCombatEffect extends AbilityDamageResource { readonly id: string; readonly kind: "dot" | "hot"; readonly sourceAbilityId: string; readonly sourceActorId: AggroActorId; readonly targetId: string | null; readonly coefficient: number; readonly romNativeSource?: AbilityAmountScaling["romNativeSource"]; /** Pre-scaled amount for non-player actors whose equipment is not the player loadout. */ readonly fixedAmount?: number; readonly percentMaxHealth?: number; readonly basePoints?: number; /** Exact per-tick amounts for periodic effects whose total must not drift from rounding. */ readonly tickAmounts?: readonly number[]; readonly dieSides?: number; readonly pointsPerLevel?: number; readonly coefficientPerLevel?: number; readonly bonusPowerCoefficient?: number; readonly attackPowerCoefficient?: number; readonly rangedAttackPowerCoefficient?: number; readonly spiritCoefficient?: number; readonly sourceLevel?: number; readonly maxScalingLevel?: number; readonly remainingTicks: number; readonly intervalMs: number; readonly nextTickAt: number; /** Extra Renew ticks granted by Holy Concentration, capped at two. */ readonly extensionTicks?: number; readonly damageSchool?: DamageSchool; /** DBC mechanic exception copied onto the effect so owner removal cannot change tick mitigation. */ readonly ignoreArmor?: boolean; /** Native Buff.Power freezes target defense and rank correction at application. */ readonly targetMitigationSnapshotted?: boolean; readonly nativePeriodicDamageSnapshot?: RomNativePeriodicDamageSnapshot; readonly spellPenetration?: number; /** Arcane Potency applies to the spell's periodic damage as well as its direct hit. */ readonly criticalChanceBonusPercent?: number; readonly tag?: string; readonly warlockSeed?: { readonly threshold: number; readonly explosionSpellId: number; readonly radius: number; readonly accumulatedDamage: number; }; /** Lightwell Renew ends when the recipient accumulates the tooltip's damage threshold. */ readonly lightwellDamageCancellation?: { readonly threshold: number; readonly accumulatedDamage: number; }; } export type CombatFeedbackKind = "success" | "error" | "damage" | "heal" | "level-up" | "target" | "talent" | "loot"; export interface CombatFeedback { readonly id: number; readonly kind: CombatFeedbackKind; readonly message: string; readonly abilityId?: string; readonly targetId?: string; readonly amount?: number; } export interface BossLootReward { readonly id: number; readonly bossId: string; readonly bossName: string; readonly items: readonly InventoryItem[]; readonly cards?: BossCardDrop; readonly acquiredAt: number; } export type CastFailureReason = | "unknown-ability" | "wrong-class" | "not-learned" | "passive" | "unsupported" | "level-locked" | "player-dead" | "player-controlled" | "already-casting" | "global-cooldown" | "cooldown" | "not-enough-resource" | "not-enough-runes" | "condition-not-met" | "no-target" | "target-dead" | "target-unavailable" | "line-of-sight" | "not-facing" | "must-be-behind" | "invalid-destination" | "school-locked" | "too-close" | "out-of-range"; export interface CastRequest { readonly origin?: CombatPosition; readonly now?: number; readonly targetActorId?: string | null; readonly groundPosition?: CombatPosition; readonly completingCast?: ActiveCast; } const WOW335_DEATH_KNIGHT_SUDDEN_DOOM_FREE_CAST = Symbol("wow335-death-knight-sudden-doom-free-cast"); type InternalCastRequest = CastRequest & { readonly [WOW335_DEATH_KNIGHT_SUDDEN_DOOM_FREE_CAST]?: true; }; export interface CastResult { readonly ok: boolean; readonly abilityId?: string; readonly reason?: CastFailureReason; readonly affectedMobIds: readonly string[]; readonly damage: number; readonly healing: number; readonly levelsGained: number; } export interface ActorHealingRequest { readonly sourceActorId: string; readonly abilityId?: string; readonly school?: DamageSchool; readonly criticalChance?: number; readonly criticalMultiplier?: number; readonly seed?: string; readonly now?: number; readonly amountAlreadyModified?: boolean; readonly romNativeSource?: AbilityAmountScaling["romNativeSource"]; } export interface CharacterCombatSeed { readonly classId: ClassId; readonly categoryId?: ContentCategoryId; readonly statRuleset?: CharacterStatRuleset; readonly hunterPetFamily?: Wow335HunterPetFamily; readonly specializationId?: string | null; readonly role?: PartyRole; readonly secondaryClassId?: ClassId | null; readonly raceId?: RaceId; readonly gender?: GenderId; readonly level?: number; readonly xp?: number; /** Backward-compatible profile field accepted by the integration bridge. */ readonly experience?: number; readonly talentRanks?: TalentRanks; readonly glyphIds?: readonly string[]; readonly actionBindings?: unknown; readonly inventory?: unknown; readonly bossCards?: unknown; readonly equipment?: unknown; readonly settings?: Partial; } export interface MobRegistration { readonly name: string; readonly serverEntry?: number; readonly sourceCreatureGuid?: number; readonly runewakerNpcId?: number; readonly creatureFamily?: CombatCreatureFamily; readonly boss?: boolean; readonly level?: number; readonly resistances?: Partial; readonly maxHealth?: number; readonly xpReward?: number; readonly healthMultiplier?: number; readonly damageMultiplier?: number; readonly nativeDamageBudgetScale?: number; readonly bonusLootChance?: number; readonly moveSpeed?: number; readonly aggroRange?: number; readonly leashRange?: number; readonly attacks?: readonly EnemyAttackDefinition[]; readonly mechanicImmunities?: readonly AzerothCoreMechanic[]; readonly schoolImmunities?: readonly AzerothCoreSchool[]; readonly hasLoot?: boolean; /** Stable SQL boss key, independent from this spawned mob's runtime id. */ readonly lootSourceId?: string; } export type TargetCycleDirection = "next" | "previous" | 1 | -1; export interface DamageResult { readonly amount: number; readonly killed: boolean; readonly xpAwarded: number; readonly levelsGained: number; readonly combatResult?: CombatResult; } export interface RoguePoisonProcResult { readonly instantProcs: number; readonly deadlyApplications: number; readonly deadlyStacks: number; readonly damage: number; } interface PlayerDamageRollContext { readonly attackKind: WotlkAttackKind; readonly seed: string; readonly abilityId?: string; /** Raw rank spell ID selected by the player/companion's build and level. */ readonly dbcSpellId?: number; readonly periodic?: boolean; readonly rules?: CombatResultRules; readonly criticalChanceBonusPercent?: number; readonly weaponSpeedMs?: number; } export interface CombatSummon { readonly id: string; readonly sourceAbilityId: string; readonly creatureId: number; readonly targetId: string | null; readonly expiresAt: number; readonly kind: "pet" | "totem" | "summon"; readonly name: string; readonly ownerActorId: AggroActorId; readonly health: number; readonly maxHealth: number; readonly position: CombatPosition; readonly nextActionAt: number; readonly pulseIntervalMs: number; readonly attackDamage: number; readonly clickCharges?: number; readonly healingPulse?: { readonly amount: number; readonly intervalMs: number; readonly radius: number }; readonly antiMagicZone?: { readonly radius: number; readonly capacityBase: number; readonly capacityPerAttackPower: number; readonly absorbFraction: number; readonly intervalMs: number }; readonly abilityIds: readonly string[]; readonly resources: CombatActorState["resources"]; readonly cooldowns: CombatActorState["cooldowns"]; readonly globalCooldownEndsAt: number; readonly cast: CombatActorState["cast"]; readonly controlledUntil: number; readonly schoolLockedUntil: CombatActorState["schoolLockedUntil"]; readonly hunterPet?: Wow335HunterPetProfile; readonly hunterPetSpecialReadyAt?: number; readonly hunterPetFuriousHowlReadyAt?: number; readonly hunterPetShellShieldReadyAt?: number; readonly hunterPetRakeReadyAt?: number; readonly hunterPetProwlReadyAt?: number; readonly hunterPetFocusUpdatedAt?: number; readonly hunterPetCommand?: Wow335HunterPetCommand; readonly hunterPetReactMode?: Wow335HunterPetReactMode; readonly hunterPetCommandTargetId?: string | null; readonly hunterPetStayPosition?: CombatPosition | null; readonly hunterPetIsCommandAttack?: boolean; readonly hunterPetIsCommandFollow?: boolean; readonly hunterPetIsReturning?: boolean; readonly hunterPetIsFollowing?: boolean; readonly hunterPetGrowlAutocastEnabled?: boolean; readonly hunterPetGrowlReadyAt?: number; readonly hunterPetPendingOwnerAttackTargetId?: string | null; readonly exclusiveGroup?: string; } export interface CombatAreaEffect { readonly id: string; readonly sourceActorId: AggroActorId; readonly sourceAbilityId: string; readonly position: CombatPosition; readonly radius: number; readonly createdAt: number; readonly expiresAt: number; readonly tickIntervalMs: number; readonly nextTickAt: number; readonly fixedDamagePerTick?: number; readonly fixedHealingPerTick?: number; readonly hunterTrap?: { readonly kind: "freezing" | "frost" | "frost-zone" | "immolation" | "explosive"; readonly triggerRadius: number; readonly freezeDurationMs?: number; readonly directDamageScaling?: AbilityAmountScaling & { readonly coefficient: number }; }; } export interface PendingGroundTarget { readonly abilityId: string; readonly maximumRange: number; readonly radius: number; readonly position: CombatPosition; readonly valid: boolean; } export interface EnemyPeriodicCombatEffect { readonly id: string; readonly sourceMob: MobCombatState; readonly attack: EnemyAttackDefinition; readonly targetActorId: AggroActorId; readonly amount: number; readonly remainingTicks: number; readonly intervalMs: number; readonly nextTickAt: number; readonly attackRevision: number; readonly nativeSnapshot?: RomNativePeriodicDamageSnapshot; } export interface CombatState { classId: ClassId; statRuleset: CharacterStatRuleset; contentCategoryId: ContentCategoryId; readonly romNativeRecoveryClock?: RomNativeRecoveryClock; hunterPetFamily: Wow335HunterPetFamily; specializationId: string | null; gearRole: PartyRole; secondaryClassId: ClassId | null; raceId: RaceId; gender: GenderId; level: number; xp: number; xpToNext: number; health: number; maxHealth: number; controlledUntil: number; controlMechanic: EnemyControlMechanic | null; shield: number; shieldExpiresAt: number; resource: number; maxResource: number; resourcePools: Readonly>>; maxResourcePools: Readonly>>; lastResourceSpentAt: Readonly>>; warriorAngerManagementRageRemainderMs: number; resourceName: string; comboPoints: number; comboTargetId: string | null; deathKnightRunes: readonly DeathKnightRuneState[]; abilities: readonly AbilityDefinition[]; cooldowns: Readonly>; globalCooldownEndsAt: number; activeCast: ActiveCast | null; schoolLockedUntil: Readonly>>; playerAnimationEvent: CharacterCombatAnimationEvent | null; autoAttackTargetId: string | null; nextAutoAttackAt: number; nextOffhandAutoAttackAt: number; autoShotTargetId: string | null; nextAutoShotAt: number; mobs: Readonly>; selectedTargetId: string | null; selectedFriendlyActorId: string | null; effects: readonly TimedCombatEffect[]; enemyPeriodicEffects: readonly EnemyPeriodicCombatEffect[]; killingSpreeWindows: readonly Wow335KillingSpreeWindow[]; /** Named buffs/debuffs for the player, mobs, and party members. */ auras: readonly ActiveAura[]; summons: readonly CombatSummon[]; areaEffects: readonly CombatAreaEffect[]; pendingGroundTarget: PendingGroundTarget | null; actors: Readonly>; combatEvents: readonly StructuredCombatEvent[]; /** Last Holy Candle self-damage trigger per owner, shared by player and companion casts. */ romPriestHolyCandleLastChargeAt: Readonly>; /** Per-actor standing progress for the exact RoM Mage/Druid Green Guardian pair. */ romMageDruidGreenGuardianStationaryProgress: Readonly>; feedback: CombatFeedback | null; /** Most recent discrete attack result, retained for proc and combat-log consumers. */ lastCombatResult: CombatResult | null; bossLootRewards: readonly BossLootReward[]; talentRanks: TalentRanks; glyphIds: readonly string[]; talentModifiers: readonly TalentModifier[]; actionBindings: ActionBindings; inventory: readonly InventoryItem[]; bossCards: BossCardCollection; collectSharedBossCards: (drop: BossCardDrop, bossName: string, now: number) => void; equipment: EquipmentAssignments; gearStats: ItemStats; settings: GameplaySettings; playerPosition: CombatPosition; initializeCharacter: (profile: CharacterCombatSeed) => void; refreshResolvedGear: () => void; registerMob: (id: string, registration: MobRegistration) => void; removeMob: (id: string) => void; lootMob: (id: string, now?: number) => boolean; dismissBossLootReward: (id?: number) => void; equipItem: (instanceId: string, ownerId?: string) => boolean; unequipItem: (ownerId: string, slot: EquipmentSlot) => boolean; selectMob: (id: string) => boolean; selectNearestTarget: (origin?: CombatPosition, maxRange?: number) => string | null; cycleTarget: (direction?: TargetCycleDirection, origin?: CombatPosition, maxRange?: number) => string | null; clearTarget: () => void; selectFriendlyActor: (id: string | null) => boolean; setPlayerPosition: (position: CombatPosition) => void; castAbility: (abilityId: string, requestOrOrigin?: CastRequest | CombatPosition, now?: number, completingCast?: ActiveCast) => CastResult; deactivateRomScoutBloodArrow: (ownerId: string, now?: number) => boolean; beginGroundTarget: (abilityId: string) => boolean; moveGroundTarget: (moveX: number, moveForward: number, yaw: number, deltaSeconds: number) => void; confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult; cancelGroundTarget: () => void; synchronizeActors: () => void; /** Grants learned long-term class stat buffs to the full party without setup casts. */ applyDefaultPartyBuffs: (now?: number) => void; /** Applies specialization maintenance buffs and permanent pets before the first pull. */ applyPrecombatPreparation: (now?: number) => void; /** Commits companion simulation results to authoritative actors and mirrors party frames. */ commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void; castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult; castActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, origin?: CombatPosition, now?: number) => CastResult; damageMob: ( id: string, amount: number, now?: number, source?: ThreatSource, school?: DamageSchool, rollContext?: PlayerDamageRollContext, /** Known ordinary auto channel for the existing no-roll companion path. */ autoAttackHand?: "mainhand" | "offhand" | "ranged", ) => DamageResult; consumeActorAuraTag: (actorId: string, tag: string) => void; consumeActorEnrage: (actorId: string, now?: number) => boolean; triggerChimeraShot: (sourceActorId: string, targetId: string, abilityId: string, now?: number) => number; triggerRogueWeaponPoisons: ( sourceActorId: AggroActorId, targetId: string, sourceAbilityId: string, hitCount?: number, now?: number, ) => RoguePoisonProcResult; consumeRogueDeadlyPoison: ( sourceActorId: AggroActorId, targetId: string, maximumDoses: number, now?: number, ) => number; addHealingThreat: (source: ThreatSource, effectiveHealing: number) => void; applyActorThreatEffect: ( actorId: AggroActorId, effect: Extract, redirectTargetActorId?: AggroActorId | null, now?: number, ) => void; tauntMob: (id: string, source: ThreatSource, durationMs: number, now?: number) => boolean; interruptMob: (id: string, sourceAbilityId: string, lockoutMs: number, now?: number) => boolean; /** One source-bound utility block result before any effects for this recipient. */ resolveMobUtilitySpellHit: (id: string, sourceActorId: string, ability: AbilityDefinition, now?: number) => CombatResult | null; controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number, breakOnDamage?: boolean, sourceActorId?: string, magnitude?: number) => boolean; dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number; healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution; /** Applies the CoA Holy Candle self-damage trigger to a companion or channel caster. */ triggerRomPriestHolyCandle: (ownerActorId: string, healingAbilityId: string, now?: number) => number; reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number; restoreActorResource: (id: string, resource: ResourceType, amount: number, percentage?: boolean, sourceActorId?: string, abilityId?: string, now?: number) => number; shieldActor: (id: string, amount: number, durationMs: number, sourceActorId: string, ability: AbilityDefinition, now?: number) => number; applyActorAura: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract, now?: number) => boolean; applyTimedActorEffect: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract, now?: number, criticalChanceBonusPercent?: number) => boolean; summonActor: (ownerActorId: string, ability: AbilityDefinition, effect: Extract, position: CombatPosition, targetId?: string | null, now?: number) => string | null; commandHunterPet: (summonId: string, command: Wow335HunterPetCommand) => boolean; setHunterPetReactMode: (summonId: string, reactMode: Wow335HunterPetReactMode) => boolean; setHunterPetFamily: (family: unknown) => boolean; setHunterPetGrowlAutocast: (summonId: string, enabled: boolean) => boolean; notifyOwnerHostileAction: (ownerActorId: string, targetId: string) => void; clickLightwell: (summonId: string, targetActorId?: string | null, now?: number) => number; createActorAreaEffect: (sourceActorId: string, ability: AbilityDefinition, position: CombatPosition, now?: number) => string | null; damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number, now?: number) => number; damageSummon: (id: string, amount: number) => number; healPlayer: (amount: number) => number; revivePlayer: (percentMaxHealth?: number) => number; /** Checkpoint recovery of the player only; keeps the party and current pull intact. */ respawnPlayerOnly: (position: CombatPosition, now?: number) => boolean; cancelCast: (message?: string, now?: number) => boolean; engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number; advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number; beginKillingSpree: ( ownerActorId: string, ability: AbilityDefinition, targetIds: readonly string[], hands: readonly Wow335KillingSpreeWeaponSnapshot[], role: PartyRole, now: number, ) => boolean; advanceKillingSpree: (now: number) => void; resetMobForNavigation: (id: string, atHome: boolean, now?: number) => boolean; tick: (deltaSeconds: number, now?: number) => void; resetEncounter: (options?: { readonly preserveDefeated?: boolean; readonly preserveDefeatedIds?: readonly string[]; }) => void; allocateTalent: (treeId: string) => boolean; setGlyphActive: (glyphId: string, active: boolean) => boolean; resetTalents: () => void; setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean; resetActionBindingLayer: (layer: ActionBindingLayer) => void; resetActionBindings: () => void; updateSettings: (patch: Partial) => void; resetSettings: () => void; } interface MutableProgression { level: number; xp: number; xpToNext: number; health: number; maxHealth: number; levelsGained: number; } interface MutableCastWork { classId: ClassId; statRuleset: CharacterStatRuleset; gender: GenderId; abilities: readonly AbilityDefinition[]; mobs: Record; effects: TimedCombatEffect[]; auras: ActiveAura[]; summons: CombatSummon[]; areaEffects: CombatAreaEffect[]; combatEvents: StructuredCombatEvent[]; romPriestHolyCandleLastChargeAt?: Record; actors: Record; cooldowns: Record; health: number; maxHealth: number; shield: number; shieldExpiresAt: number; resource: number; maxResource: number; resourcePools: Partial>; maxResourcePools: Partial>; lastResourceSpentAt: Partial>; comboPoints: number; comboTargetId: string | null; deathKnightRunes: readonly DeathKnightRuneState[]; progression: MutableProgression; totalDamage: number; totalHealing: number; combatResults: CombatResult[]; talentRanks: TalentRanks; talentModifiers: readonly TalentModifier[]; gearStats: ItemStats; raceId: RaceId; secondaryClassId: ClassId | null; /** Active synchronous spell/source/target keys used to break proc cycles. */ triggeredSpellStack?: string[]; triggeredProcKeys?: Set; /** A cast can model one weapon hit as several damage packets. */ scentOfBloodHitKeys?: Set; killingSpreeWindows?: Wow335KillingSpreeWindow[]; } let feedbackSequence = 0; let bossLootRewardSequence = 0; let effectSequence = 0; let castSequence = 0; let animationSequence = 0; let summonSequence = 0; let areaEffectSequence = 0; let combatEventSequence = 0; function initialSummonCombatRuntime(): Pick { return { abilityIds: [], resources: {}, cooldowns: {}, globalCooldownEndsAt: 0, cast: null, controlledUntil: 0, schoolLockedUntil: {}, }; } function initialHunterPetCombatRuntime(profile: Wow335HunterPetProfile, now: number): Pick { return { hunterPet: profile, hunterPetSpecialReadyAt: now, hunterPetFuriousHowlReadyAt: now, hunterPetShellShieldReadyAt: profile.shellShield ? now : undefined, hunterPetRakeReadyAt: profile.rake ? now : undefined, hunterPetProwlReadyAt: profile.prowl ? now : undefined, hunterPetFocusUpdatedAt: now, hunterPetCommand: WOW335_HUNTER_PET_DEFAULT_COMMAND, hunterPetReactMode: WOW335_HUNTER_PET_DEFAULT_REACT_MODE, hunterPetCommandTargetId: null, hunterPetStayPosition: null, hunterPetIsCommandAttack: false, hunterPetIsCommandFollow: false, hunterPetIsReturning: false, hunterPetIsFollowing: true, hunterPetGrowlAutocastEnabled: false, hunterPetGrowlReadyAt: now, hunterPetPendingOwnerAttackTargetId: null, resources: { focus: normalizeActorResourcePool("focus", 100, 100, 5, now), }, }; } function combatActorFromSummon( summon: CombatSummon, level: number, auras: readonly ActiveAura[], statRuleset: CharacterStatRuleset = "wow", ): CombatActorState { return { id: summon.id, threatActorId: summon.id, kind: summon.kind, ownerId: summon.ownerActorId, statRuleset, level, health: summon.health, maxHealth: summon.maxHealth, absorb: entityAbsorbAmount(auras, summon.id), resources: summon.resources, cooldowns: summon.cooldowns, globalCooldownEndsAt: summon.globalCooldownEndsAt, cast: summon.cast, controlledUntil: summon.controlledUntil, schoolLockedUntil: summon.schoolLockedUntil, comboPoints: 0, comboTargetId: null, runes: [], abilityIds: summon.abilityIds, alive: summon.health > 0, }; } function recordCombatEvent( events: readonly StructuredCombatEvent[], input: CombatEventInput, ): readonly StructuredCombatEvent[] { combatEventSequence += 1; return appendCombatEvent(events, input, combatEventSequence); } function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput): void { work.combatEvents = [...recordCombatEvent(work.combatEvents, input)]; } function hasRecentPlayerCriticalHit(events: readonly StructuredCombatEvent[], playerActorId: string): boolean { for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index]!; if (event.kind !== "damage" || event.sourceActorId !== playerActorId) continue; return event.critical && event.effectiveAmount > 0; } return false; } function nextFeedback( kind: CombatFeedbackKind, message: string, details: Omit = {}, ): CombatFeedback { feedbackSequence += 1; return { id: feedbackSequence, kind, message, ...details }; } function nextAnimationEvent( kind: CharacterCombatAnimationKind, abilityId?: string, ): CharacterCombatAnimationEvent { animationSequence += 1; return { revision: animationSequence, kind, ...(abilityId ? { abilityId } : {}) }; } function emitPlayerAbilityPresentation( ability: AbilityDefinition, phase: CombatPresentationPhase, occurredAt: number, origin: CombatPosition, targetId: string | null, targetPosition: CombatPosition | null, instantCast = false, penanceHostile = false, ): void { const penancePulse = ability.id === "wow335-priest-penance" && phase === "tick"; const delivery = penancePulse ? "projectile" : presentationDeliveryForAbility(ability); const presentationTargetId = targetId ?? (delivery === "aura" ? PLAYER_AURA_ENTITY_ID : null); const event = { occurredAt, phase, actorGroup: "player", sourceActorId: PLAYER_AGGRO_ID, ...(presentationTargetId ? { targetActorId: presentationTargetId } : {}), abilityId: ability.id, sourceSpellId: penancePulse ? penancePulseVisualSpellId(penanceHostile) : ability.dbcSpellId, name: ability.name, source: presentationSourceForAbility(ability), school: presentationSchoolForAbility(ability), delivery, origin, targetPosition: targetPosition ?? origin, ...(ability.radius === undefined ? {} : { radius: ability.radius }), ...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}), ...(instantCast ? { instantCast: true } : {}), } as const; emitCombatPresentation(event); if (penancePulse) emitCombatPresentation({ ...event, phase: "impact" }); } function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void { const ability = abilityById(abilityId); if (!ability) return; emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin); } function emitPlayerAbilityResolutionPresentation( ability: AbilityDefinition, occurredAt: number, origin: CombatPosition, targetId: string | null, targetPosition: CombatPosition | null, instantCast: boolean, ): void { emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition, instantCast); if (ability.id === "wow335-priest-penance") return; emitPlayerAbilityPresentation( ability, presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact", occurredAt, origin, targetId, targetPosition, instantCast, ); } function emitEnemyAttackPresentation( mob: MobCombatState, attack: EnemyAttackDefinition, phase: CombatPresentationPhase, occurredAt: number, origin: CombatPosition, targetActorId: AggroActorId | null, targetPosition: CombatPosition | null, instantCast = false, ): void { emitCombatPresentation({ occurredAt, phase, actorGroup: "enemy", sourceActorId: mob.id, ...(targetActorId ? { targetActorId } : {}), abilityId: attack.id, ...(attack.spellId === undefined ? {} : { sourceSpellId: attack.spellId }), name: attack.name, source: presentationSourceForSpellId(attack.spellId), school: attack.school, delivery: attack.delivery, origin, targetPosition: targetPosition ?? origin, ...(attack.radius === undefined ? {} : { radius: attack.radius }), ...((attack.castTimeMs ?? 0) > 0 ? { durationMs: attack.castTimeMs } : {}), ...(instantCast ? { instantCast: true } : {}), }); } function emptyCastResult(reason?: CastFailureReason, abilityId?: string): CastResult { return { ok: !reason, abilityId, reason, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 }; } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function forwardMovementDestination( origin: CombatPosition, yaw: number, distance: number, ): CombatPosition | null { if (!Number.isFinite(yaw) || !Number.isFinite(distance) || distance <= 0) return null; const requestedAt = (forwardDistance: number): CombatPosition => [ origin[0] + Math.sin(yaw) * forwardDistance, origin[1], origin[2] + Math.cos(yaw) * forwardDistance, ]; const projectAt = (forwardDistance: number) => projectCombatGroundPoint( requestedAt(forwardDistance), forwardDistance, origin, ); const fullDistancePoint = projectAt(distance); if (fullDistancePoint && combatHasLineOfSight(origin, fullDistancePoint)) return fullDistancePoint; // A blocked endpoint means Blink travels only as far as the first obstacle. // Project each sample onto ground while searching the visible segment. let reachableDistance = 0; let blockedDistance = distance; let reachablePoint: CombatPosition | null = null; for (let attempt = 0; attempt < 12; attempt += 1) { const sampleDistance = (reachableDistance + blockedDistance) / 2; const sample = projectAt(sampleDistance); if (sample && combatHasLineOfSight(origin, sample)) { reachableDistance = sampleDistance; reachablePoint = sample; } else { blockedDistance = sampleDistance; } } return reachablePoint; } const ROM_RAGE_CLASSES = new Set(["rom-warrior", "rom-champion"]); const RAGE_PER_AUTO_ATTACK = 6; const RAGE_PER_HIT_TAKEN = 7; const ROM_CHAMPION_RAGE_PER_SECOND = 6; const ROM_WARRIOR_SCOUT_CALM_PASSIVE_SPELL_ID = 498032; const ROM_WARRIOR_SCOUT_SKULL_BREAKER_SPELL_ID = 491328; const ROM_WARRIOR_SCOUT_CALM_TAG = "rom-warrior-scout-calm"; const ROM_WARRIOR_SCOUT_CALM_AURA: AuraDefinition = { id: "rom:498032:component:620337", name: "Calm", disposition: "buff", durationMs: 15_000, utilityTags: [ROM_WARRIOR_SCOUT_CALM_TAG], modifiers: [{ kind: "stat", stat: "physical-hit", operation: "flat", value: 20 }], }; const PLAYER_AUTO_ATTACK_RANGE = 5; const PLAYER_OFFHAND_AUTO_ATTACK_ID = "player-auto-attack-offhand"; const ROGUE_COMBAT_POTENCY_ENERGY = 5; const ROGUE_COMBAT_POTENCY_COOLDOWN_ABILITIES = new Set([ "Blade Flurry", "Killing Spree", "Combat Readiness", ]); function hasRomClassPool( classId: ClassId, secondaryClassId: ClassId | null | undefined, resourceClassId: ClassId, ): boolean { return classId === resourceClassId || secondaryClassId === resourceClassId; } function hasRomRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean { return ROM_RAGE_CLASSES.has(classId) || Boolean(secondaryClassId && ROM_RAGE_CLASSES.has(secondaryClassId)); } function hasRomWarriorRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean { return hasRomClassPool(classId, secondaryClassId, "rom-warrior"); } function hasRomChampionRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean { return hasRomClassPool(classId, secondaryClassId, "rom-champion"); } function gainRomRage( classId: ClassId, secondaryClassId: ClassId | null | undefined, amount: number, resource: number, maxResource: number, resourcePools: Partial>, maxResourcePools: Partial>, ): { resource: number; resourcePools: Partial> } { const activeType = resourceProfileForClass(classId).type; const hasRage = hasRomRagePool(classId, secondaryClassId) || activeType === "rage" || maxResourcePools.rage !== undefined; if (!hasRage || amount <= 0) return { resource, resourcePools }; const maximum = maxResourcePools.rage ?? (activeType === "rage" ? maxResource : 100); const rage = clamp((resourcePools.rage ?? (activeType === "rage" ? resource : 0)) + amount, 0, maximum); return { resource: activeType === "rage" ? rage : resource, resourcePools: { ...resourcePools, rage }, }; } function applyRomCoaExhaustionProcInWork( work: MutableCastWork, ability: AbilityDefinition, attackLanded: boolean, roll: number, now: number, ): void { if (ability.dbcSpellId !== ROM_COA_WARRIOR_ROGUE_PROBING_ATTACK_ID) return; const learnedPassiveSpellIds = work.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-warrior" && candidate.secondaryClassId === "rom-rogue" && candidate.dbcSpellId === ROM_COA_WARRIOR_ROGUE_EXHAUSTION_ID && candidate.passive && learnedAbilityForBuild(candidate, work.progression.level, work.talentRanks) !== null) .map((candidate) => candidate.dbcSpellId); const proc = romCoaWarriorRogueExhaustionProc({ classId: work.classId, secondaryClassId: work.secondaryClassId, learnedPassiveSpellIds, triggerSpellId: ability.dbcSpellId, attackLanded, roll, }); if (!proc.procced) return; const energyMaximum = work.maxResourcePools.energy ?? defaultResourceMaximum("energy"); const energyBefore = work.resourcePools.energy ?? 0; const energyAfter = Math.min(energyMaximum, energyBefore + proc.energy); const energyRestored = energyAfter - energyBefore; work.resourcePools.energy = energyAfter; if (resourceProfileForClass(work.classId).type === "energy") work.resource = energyAfter; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AGGRO_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, rawAmount: proc.energy, effectiveAmount: energyRestored, overheal: proc.energy - energyRestored, absorbed: 0, critical: false, }); const rageBefore = work.resourcePools.rage ?? (resourceProfileForClass(work.classId).type === "rage" ? work.resource : 0); const rage = gainRomRage( work.classId, work.secondaryClassId, proc.rage, work.resource, work.maxResource, work.resourcePools, work.maxResourcePools, ); const rageAfter = rage.resourcePools.rage ?? rageBefore; const rageRestored = rageAfter - rageBefore; work.resource = rage.resource; work.resourcePools = rage.resourcePools; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AGGRO_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, rawAmount: proc.rage, effectiveAmount: rageRestored, overheal: proc.rage - rageRestored, absorbed: 0, critical: false, }); } function romPriestChampionRageControlAwardPatch( state: CombatState, cast: ActiveCast, endedAt: number, ): Partial { if (cast.mode !== "channel" || state.classId !== "rom-priest" || state.secondaryClassId !== "rom-champion") return {}; const hasRageControl = state.abilities.some((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-priest" && candidate.secondaryClassId === "rom-champion" && candidate.dbcSpellId === 498610 && candidate.passive && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= state.level); const durationMs = Math.max(0, Math.min(endedAt, cast.completesAt) - cast.startedAt); const gain = romPriestChampionRageControlGain(hasRageControl, { kind: "channel", durationMs }); if (gain <= 0) return {}; const rage = gainRomRage( state.classId, state.secondaryClassId, gain, state.resource, state.maxResource, state.resourcePools, state.maxResourcePools, ); return { resource: rage.resource, resourcePools: rage.resourcePools }; } function playerAutoAttackProfile( state: Omit, "statRuleset"> & { readonly statRuleset?: CharacterStatRuleset }, auras: readonly ActiveAura[] = [], weaponOverride?: WeaponStats | null, now = combatNow(), ): { damage: number; intervalMs: number; hasteMultiplier: number } { auras = effectiveActorAuras(auras, PLAYER_AURA_ENTITY_ID, now, "main-hand"); const stats = applyAuraStats(deriveCharacterStats( state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset, ), auras); const nativeAuto = weaponOverride !== null ? activeEnemyDisarmChannels(auras, PLAYER_AURA_ENTITY_ID, now).has("main-hand") ? nativeDisarmedMainHandAutoAttackProfile(stats.romNative) : nativeTrainingAutoAttackProfile(stats.romNative, "main-hand") : undefined; if (nativeAuto) { const nativeAuraHaste = resolveAuraStatValue(stats.meleeHastePercent, auras, "melee-haste"); const intervalMs = Math.max(500, Math.round(hastedDuration(nativeAuto.intervalMs, nativeAuraHaste) / raceAffixHasteMultiplier())); return { damage: Math.max(1, Math.round(nativeAuto.damage)), intervalMs, hasteMultiplier: nativeAuto.intervalMs / intervalMs }; } const feralForm = state.classId === "druid" ? wow335DruidShapeshiftFormForAuras(auras, PLAYER_AURA_ENTITY_ID, now) : undefined; const disarmedMainHand = normalizeCharacterStatRuleset(state.statRuleset, state.classId) === "wow" && !(feralForm === "cat" || feralForm === "bear") && activeEnemyDisarmChannels(auras, PLAYER_AURA_ENTITY_ID, now).has("main-hand"); const weapon = disarmedMainHand ? ENEMY_DISARM_UNARMED_MAIN_HAND : weaponOverride === undefined ? state.gearStats.meleeWeapon : weaponOverride; const speedMs = weapon?.speedMs ?? 2_400; const speedSeconds = speedMs / 1_000; const weaponDamage = weapon ? (weapon.damageMin + weapon.damageMax) / 2 : playerCombatPower(state.level) * 0.35; const intervalMs = Math.max(500, Math.round(hastedDuration(speedMs, stats.meleeHastePercent) / raceAffixHasteMultiplier())); return { damage: Math.max(1, Math.round(weaponDamage + stats.attackPower / 14 * speedSeconds)), intervalMs, hasteMultiplier: speedMs / intervalMs, }; } function playerAutoShotProfile( state: Pick, weapon: WeaponStats, auras: readonly ActiveAura[], now: number, ): { damage: number; intervalMs: number; weaponSpeedMs: number } { auras = effectiveActorAuras(auras, PLAYER_AURA_ENTITY_ID, now, "ranged"); const stats = applyAuraStats(deriveCharacterStats( state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset, ), auras); const nativeAuto = nativeTrainingAutoAttackProfile(stats.romNative, "ranged"); const baseIntervalMs = nativeAuto?.intervalMs ?? weapon.speedMs; const rangedWeaponDps = nativeAuto ? nativeAuto.damage / Math.max(0.1, nativeAuto.intervalMs / 1_000) : weaponDps(weapon); // Native RoM stat projection intentionally leaves semantic aura haste out of // CharacterStats. Resolve it here just as native melee/ranged schedulers do; // compatibility stats already include the aura and must not apply it twice. const rangedHastePercent = stats.romNative ? ["haste", "ranged-haste", "attack-speed"].reduce( (current, stat) => resolveAuraStatValue(current, auras, stat), stats.rangedHastePercent, ) : stats.rangedHastePercent; return { damage: Math.max(1, Math.round(rangedWeaponDps * ROM_SCOUT_AUTO_SHOT_DAMAGE_COEFFICIENT)), weaponSpeedMs: baseIntervalMs, intervalMs: Math.max(500, Math.round(hastedDuration( baseIntervalMs, rangedHastePercent, ) / raceAffixHasteMultiplier(now))), }; } function playerOffhandAutoAttackProfile( state: Pick, weapon: WeaponStats, auras: readonly ActiveAura[] = [], offHandDamageBonusPercent = 0, now = combatNow(), ): { damage: number; intervalMs: number } { auras = effectiveActorAuras(auras, PLAYER_AURA_ENTITY_ID, now, "off-hand"); const stats = applyAuraStats(deriveCharacterStats( state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset, ), auras); const nativeAuto = nativeTrainingAutoAttackProfile(stats.romNative, "off-hand"); if (nativeAuto) return { damage: Math.max(1, Math.round(nativeAuto.damage * (1 + offHandDamageBonusPercent / 100))), intervalMs: Math.max(500, Math.round(hastedDuration(nativeAuto.intervalMs, resolveAuraStatValue(stats.meleeHastePercent, auras, "melee-haste")) / raceAffixHasteMultiplier())) }; const speedSeconds = weapon.speedMs / 1_000; const fullWeaponDamage = (weapon.damageMin + weapon.damageMax) / 2 + stats.attackPower / 14 * speedSeconds; return { damage: Math.max(1, Math.round(fullWeaponDamage * 0.5 * (1 + offHandDamageBonusPercent / 100))), intervalMs: Math.max(500, Math.round(hastedDuration(weapon.speedMs, stats.meleeHastePercent) / raceAffixHasteMultiplier())), }; } function playerEquippedMeleeWeapon( state: Pick, slot: "main-hand" | "off-hand", ): WeaponStats | null { // The resolved loadout fills empty slots with generated heirlooms. Those // are baseline stats, not a player-equipped second weapon, so only inspect // explicit inventory assignments when deciding whether this hand swings. const assignedId = state.equipment[PLAYER_EQUIPMENT_OWNER_ID]?.[slot]; const item = assignedId ? state.inventory.find((candidate) => candidate.instanceId === assignedId) : null; return item?.weaponType ? item.stats.meleeWeapon : null; } function playerEquippedRangedWeapon( state: Pick, ): WeaponStats | null { // Use the same authoritative loadout path as all other equipment checks: // Scouts receive their Training Gear bow automatically, while a valid // assigned ranged item takes precedence over that baseline. const item = resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, }).ranged; return item?.inventorySlot === "ranged" && item.weaponType ? item.stats.rangedWeapon : null; } function abilityTalentContext(ability: AbilityDefinition, periodic = false): TalentModifierContext { const searchable = `${ability.id} ${ability.name} ${ability.description} ${ability.icon}`.toLowerCase(); const tags = new Set(); tags.add(ability.castTimeMs > 0 ? "cast-time" : "instant"); if (ability.castMode === "channel" || (ability.channelTickMs ?? 0) > 0) tags.add("channel"); if (periodic) tags.add("periodic"); if (ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "finisher-damage" || effect.kind === "dot")) tags.add("damage"); if (ability.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield")) tags.add("healing"); for (const school of ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) { if (searchable.includes(school)) tags.add(school); } return { abilityName: ability.name, tags: [...tags] }; } type RomCoaThreatState = Pick & Partial>; function learnedRomCoaPassives( classId: ClassId, secondaryClassId: ClassId | null | undefined, level: number, talentRanks: Readonly>, ): number[] { return abilitiesForCharacter(classId, secondaryClassId) .filter((candidate) => candidate.source === "runewaker" && candidate.passive && (candidate.classId === classId ? (!candidate.secondaryClassId || candidate.secondaryClassId === secondaryClassId) : candidate.classId === secondaryClassId && candidate.romSkillGroup === "general") && learnedAbilityForBuild(candidate, level, talentRanks) !== null) .map((candidate) => candidate.dbcSpellId); } function romPriestScoutSaintsBlessingMultiplierForHit( work: MutableCastWork, fallbackClassId: ClassId, sourceActorId: string, mob: MobCombatState, sourcePartyMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId), ): number { const primaryClassId = sourcePartyMember?.classId ?? fallbackClassId; const secondaryClassId = sourcePartyMember?.secondaryClassId ?? work.secondaryClassId; if (primaryClassId !== "rom-priest" || secondaryClassId !== "rom-scout") return 1; const learnedPassiveSpellIds = learnedRomCoaPassives( primaryClassId, secondaryClassId, sourcePartyMember?.level ?? work.progression.level, sourcePartyMember?.talentRanks ?? work.talentRanks, ); return romPriestScoutSaintsBlessingDamageMultiplier({ primaryClassId, secondaryClassId, learnedPassiveSpellIds, targetHealthPercent: mob.maxHealth > 0 ? mob.health / mob.maxHealth : Number.NaN, }); } function activeLocalBlockingStance( auras: readonly ActiveAura[], actorId: string, now: number, ): boolean { return auras.some((aura) => aura.targetId === actorId && aura.definition.id === ROM_LOCAL_ACTIVE_BLOCKING_STANCE_AURA_ID && (aura.expiresAt === null || aura.expiresAt > now)); } function romCoaThreatMultiplierForBuild(input: { readonly ability: AbilityDefinition | null; readonly classId: ClassId; readonly secondaryClassId?: ClassId | null; readonly level: number; readonly talentRanks: Readonly>; readonly auras: readonly ActiveAura[]; readonly actorId: string; readonly now: number; }): number { if (!input.ability || input.ability.source !== "runewaker") return 1; const passives = learnedRomCoaPassives( input.classId, input.secondaryClassId, input.level, input.talentRanks, ); return romCurrentCoaThreatFactors({ ability: input.ability, classId: input.classId as AbilityDefinition["classId"], ...(input.secondaryClassId ? { secondaryClassId: input.secondaryClassId as AbilityDefinition["secondaryClassId"] } : {}), learnedPassiveSpellIds: passives, blockingStanceAuraActive: activeLocalBlockingStance(input.auras, input.actorId, input.now), }).resolvedMultiplier; } const ROM_COA_LEOPARD_INSTINCT_STACK_AURA_ID = "rom-coa-leopard-instinct-stacks-495402"; function romCoaIncomingDamageAfterPassives(input: { readonly amount: number; readonly classId: ClassId; readonly secondaryClassId?: ClassId | null; readonly level: number; readonly talentRanks: Readonly>; readonly auras: readonly ActiveAura[]; readonly actorId: string; readonly school: DamageSchool; readonly now: number; }): number { const learnedPassiveSpellIds = learnedRomCoaPassives( input.classId, input.secondaryClassId, input.level, input.talentRanks, ); const blockingStanceAuraActive = activeLocalBlockingStance(input.auras, input.actorId, input.now); const blocking = romCurrentCoaBlockingStanceModifiers({ classId: input.classId as AbilityDefinition["classId"], ...(input.secondaryClassId ? { secondaryClassId: input.secondaryClassId as AbilityDefinition["secondaryClassId"] } : {}), learnedPassiveSpellIds, blockingStanceAuraActive, }); const resist = romCurrentCoaResistModifiers({ classId: input.classId as AbilityDefinition["classId"], ...(input.secondaryClassId ? { secondaryClassId: input.secondaryClassId as AbilityDefinition["secondaryClassId"] } : {}), learnedPassiveSpellIds, blockingStanceAuraActive, }); const afterBlockingStance = input.amount * (1 - blocking.receivedDamageReductionPercent / 100); const afterResist = input.school === "physical" ? afterBlockingStance * resist.physicalDamageTakenMultiplier : afterBlockingStance; return Math.max(0, Math.round(afterResist)); } function updateRomLeopardInstinctStacks( auras: readonly ActiveAura[], actorId: string, learnedPassive: boolean, outcome: "dodge" | "hit" | "other", now: number, ): readonly ActiveAura[] { if (!learnedPassive || outcome === "other") return auras; const existing = auras.find((aura) => aura.targetId === actorId && aura.definition.id === ROM_COA_LEOPARD_INSTINCT_STACK_AURA_ID); const current = createRomLeopardInstinctState(existing?.stacks ?? 0); const next = outcome === "dodge" ? romLeopardInstinctOnDodge(current) : romLeopardInstinctOnHitReceived(current); if (next.stacks === current.stacks) return auras; const withoutExisting = auras.filter((aura) => aura !== existing); if (next.stacks === 0) return withoutExisting; const definition: AuraDefinition = { id: ROM_COA_LEOPARD_INSTINCT_STACK_AURA_ID, name: "Leopard Instinct", description: "Current CoA passive stacks: +3 percentage points Dodge per stack. Its separate Magic Resistance Rate bonus has no runtime consumer.", disposition: "buff", durationMs: null, maxStacks: 10, stackBehavior: "replace", modifiers: [{ kind: "stat", stat: "dodge", operation: "flat", value: 3, perStack: true }], }; return [...withoutExisting, { instanceId: existing?.instanceId ?? `${ROM_COA_LEOPARD_INSTINCT_STACK_AURA_ID}:${actorId}`, definition, sourceId: actorId, targetId: actorId, appliedAt: existing?.appliedAt ?? now, expiresAt: null, stacks: next.stacks, absorbs: [], procStates: [], }]; } function playerThreatSource( state: RomCoaThreatState, abilityId?: string, periodic = false, ): ThreatSource { const ability = abilityId ? abilityById(abilityId) : null; const threatScale = ability ? applyTalentModifierValue( 1, state.talentModifiers, "threat-percent", abilityTalentContext(ability, periodic), ) : 1; const coaThreatScale = state.classId && state.level !== undefined && state.abilities && state.talentRanks && state.auras ? romCoaThreatMultiplierForBuild({ ability, classId: state.classId, secondaryClassId: state.secondaryClassId, level: state.level, talentRanks: state.talentRanks, auras: state.auras, actorId: PLAYER_AURA_ENTITY_ID, now: combatNow(), }) : 1; return { actorId: PLAYER_AGGRO_ID, role: usePartyStore.getState().playerRole, ...(abilityId ? { abilityId } : {}), threatScale: threatScale * coaThreatScale, }; } function threatSourceForActor( state: RomCoaThreatState, actorId: AggroActorId, abilityId?: string, periodic = false, ): ThreatSource { if (actorId === PLAYER_AGGRO_ID) return playerThreatSource(state, abilityId, periodic); const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId); const ability = abilityId ? abilityById(abilityId) : null; const coaThreatScale = member ? romCoaThreatMultiplierForBuild({ ability, classId: member.classId, secondaryClassId: member.secondaryClassId, level: member.level, talentRanks: member.talentRanks, auras: useCombatStore.getState().auras, actorId, now: combatNow(), }) : 1; return { actorId, role: member?.role ?? "damage", ...(abilityId ? { abilityId } : {}), threatScale: coaThreatScale, }; } function withCurrentCoaThreatFactor( state: CombatState, source: ThreatSource, now: number, ): ThreatSource { if (source.threatScale !== undefined) return source; const ability = source.abilityId ? abilityById(source.abilityId) : null; if (source.actorId === PLAYER_AGGRO_ID) { return { ...source, threatScale: romCoaThreatMultiplierForBuild({ ability, classId: state.classId, secondaryClassId: state.secondaryClassId, level: state.level, talentRanks: state.talentRanks, auras: state.auras, actorId: PLAYER_AURA_ENTITY_ID, now, }), }; } const member = usePartyStore.getState().members.find((candidate) => candidate.id === source.actorId); if (!member) return source; return { ...source, threatScale: romCoaThreatMultiplierForBuild({ ability, classId: member.classId, secondaryClassId: member.secondaryClassId, level: member.level, talentRanks: member.talentRanks, auras: state.auras, actorId: member.id, now, }), }; } const TRICKS_THREAT_REDIRECT_GROUP_PREFIX = "wow335:rogue:tricks-target:"; function activeTricksThreatRecipient( auras: readonly ActiveAura[], sourceActorId: AggroActorId, now: number, ): AggroActorId | null { const redirect = auras.find((aura) => ( aura.targetId === sourceActorId && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.exclusiveGroup?.startsWith(TRICKS_THREAT_REDIRECT_GROUP_PREFIX) )); return redirect?.definition.exclusiveGroup?.slice(TRICKS_THREAT_REDIRECT_GROUP_PREFIX.length) || null; } function actorPosition(state: Pick, actorId: AggroActorId): CombatPosition | null { if (actorId === PLAYER_AGGRO_ID) return state.playerPosition; return getPartyRuntimePosition(actorId) ?? state.summons.find((summon) => summon.id === actorId && summon.health > 0)?.position ?? null; } function eligibleAggroActorIds( state: Pick, mob: Pick, ): Set { const eligible = new Set(); const home = mob.homePosition; const withinLeash = (position: CombatPosition | null) => { if (!position) return false; if (!home) return true; return planarDistanceSquared(position, home) <= mob.leashRange * mob.leashRange; }; if (state.health > 0 && withinLeash(state.playerPosition)) eligible.add(PLAYER_AGGRO_ID); for (const member of usePartyStore.getState().members) { if (member.health > 0 && withinLeash(getPartyRuntimePosition(member.id))) eligible.add(member.id); } for (const actor of onlinePlayerRuntimeActors()) { if (actor.health > 0 && withinLeash(getPartyRuntimePosition(actor.id))) eligible.add(actor.id); } for (const summon of state.summons) { if (summon.health > 0 && withinLeash(summon.position)) eligible.add(summon.id); } return eligible; } function addHealingThreatInWork( work: MutableCastWork, source: ThreatSource, effectiveHealing: number, now = combatNow(), ): void { const engagedIds = Object.values(work.mobs) .filter((mob) => mob.engaged && !mob.dead) .map((mob) => mob.id); const perMob = threatFromHealing(effectiveHealing, engagedIds.length) * (source.threatScale ?? 1) * resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat"); if (perMob <= 0) return; const threatActorId = activeTricksThreatRecipient(work.auras, source.actorId, now) ?? source.actorId; for (const id of engagedIds) { const mob = work.mobs[id]; work.mobs[id] = { ...mob, threatByActor: addActorThreat(mob.threatByActor, threatActorId, perMob), }; } } function talentAdjustedAmount( baseValue: number, modifiers: readonly TalentModifier[], kind: "damage-percent" | "healing-percent", ability: AbilityDefinition, periodic = false, ): number { const context = abilityTalentContext(ability, periodic); const scalar = applyTalentModifierValue( baseValue, modifiers, kind, context, ); return Math.max(0, Math.round(scalar)); } function priestTestOfFaithAmount( amount: number, classId: ClassId, talentRanks: TalentRanks, ability: AbilityDefinition, kind: "damage" | "healing", targetHealth: number, targetMaxHealth: number, ): number { if (classId !== "priest" || targetMaxHealth <= 0 || targetHealth > targetMaxHealth / 2) return amount; if (kind === "damage" && !["Smite", "Holy Fire"].includes(ability.name)) return amount; const talentRank = Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( talentNodeById(nodeId)?.name === "Test of Faith" ? Math.max(rank, points) : rank ), 0); return talentRank > 0 ? Math.round(amount * (1 + Math.min(3, talentRank) * 0.04)) : amount; } function priestTestOfFaithIncomingDamage( amount: number, classId: ClassId, talentRanks: TalentRanks, activeCast: ActiveCast | null, school: DamageSchool, now: number, ): number { if (amount <= 0 || classId !== "priest" || school === "physical" || !activeCast || activeCast.startedAt > now || activeCast.completesAt <= now) return amount; const ability = abilityById(activeCast.abilityId); const isEligibleCast = Boolean(ability && ( ability.name === "Smite" || ability.name === "Holy Fire" || ability.effects.some((effect) => ( effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield" || effect.kind === "resurrection" )) )); if (!isEligibleCast) return amount; const talentRank = Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( talentNodeById(nodeId)?.name === "Test of Faith" ? Math.max(rank, points) : rank ), 0); return talentRank > 0 ? Math.round(amount * 0.9) : amount; } function talentAdjustedMaxHealth( classId: ClassId, level: number, modifiers: readonly TalentModifier[], gearStats: ItemStats = EMPTY_ITEM_STATS, raceId: RaceId = "human", secondaryClassId?: ClassId | null, gender: GenderId = "male", statRuleset: CharacterStatRuleset = "wow", ): number { const playerModifiers = modifiers.filter((modifier) => ( !/\b(?:pet|imp|voidwalker|succubus|felhunter|felguard)\b/i.test(modifier.scope) )); const staminaModifiers = playerModifiers.filter((modifier) => ( modifier.kind !== "attribute-percent" || modifier.attribute === "stamina" )); const staminaPercent = applyTalentModifierValue(100, staminaModifiers, "attribute-percent") - 100; const maxHealthPercent = applyTalentModifierValue(100, playerModifiers, "max-health-percent") - 100; return Math.max(1, deriveCharacterStats(classId, level, gearStats, raceId, { stamina: staminaPercent, maxHealth: maxHealthPercent, }, secondaryClassId, gender, statRuleset).health); } function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind { if (ability.sourceAttackKind) return ability.sourceAttackKind; if (romWarriorPhysicalWeaponTooltip(ability)) return "melee"; if (ability.effects.some((effect) => effect.kind === "damage" && effect.weaponHand)) return "melee"; if (ability.source === "runewaker") { const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot"); if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school && sourceHit.school !== "physical") { return "spell"; } } const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase(); const healing = ability.effects.some((effect) => ( effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield" || effect.kind === "resurrection" )); const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable); const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable); if (healing && ability.target !== "hostile") return "spell"; if (ability.range.max > 5 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged"; if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee"; return "spell"; } export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool { if (romWarriorPhysicalWeaponTooltip(ability)) return "physical"; if (classId === "hunter" && ability.name === "Explosive Trap") return "fire"; if (ability.source === "runewaker") { const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot"); if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school) return sourceHit.school; } if (["Envenom", "Instant Poison", "Deadly Poison"].includes(ability.name)) return "nature"; if (abilityAttackKind(classId, ability) !== "spell") return "physical"; const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase(); if (/\b(?:fire|flame|immolate|incinerate|searing|lava)\b/.test(searchable)) return "fire"; if (/\b(?:frost|ice|chill|blizzard)\b/.test(searchable)) return "frost"; if (/\b(?:shadow|curse|corruption|drain|haunt)\b/.test(searchable)) return "shadow"; if (/\b(?:arcane|starfire|moonfire)\b/.test(searchable)) return "arcane"; if (/\b(?:nature|lightning|wrath|insect|earth|storm)\b/.test(searchable)) return "nature"; if (classId === "paladin" || classId === "priest") return "holy"; if (classId === "warlock") return "shadow"; if (classId === "shaman" || classId === "druid") return "nature"; return "arcane"; } export function equipmentAdjustedCombatAmount( scaling: number | (AbilityAmountScaling & { readonly coefficient: number }), level: number, gearStats: ItemStats, classId: ClassId, ability: AbilityDefinition, holyBlessingSpellPowerBonus = 0, intellectOverride?: number, raceId: RaceId = "human", gender: GenderId = "male", statRuleset: CharacterStatRuleset = "wow", secondaryClassId?: ClassId | null, effectiveNativeStats?: RomNativeDerivedStats, nativeTargetContext?: RomNativeDamageTargetContext, disabledWeaponChannels?: ReadonlySet, ): number { const kind = typeof scaling !== "number" && "school" in scaling && scaling.school && scaling.school !== "physical" ? "spell" : abilityAttackKind(classId, ability); const nativeStats = normalizeCharacterStatRuleset(statRuleset, classId) === "rom" ? deriveCharacterStats(classId, level, gearStats, raceId, {}, secondaryClassId, gender, statRuleset) : null; if (nativeStats?.romNative && typeof scaling !== "number") { const effective = effectiveNativeStats ?? nativeStats.romNative; const nativeAmount = nativeHolyHealCasterAmount(scaling, level, effective) ?? nativeDamageCasterAmount(scaling, level, effective, nativeTargetContext, disabledWeaponChannels?.has("main-hand") ? nativeDisarmedMainHandAutoAttackProfile(effective) : undefined) ?? nativePeriodicHealSnapshot(scaling, level, effective)?.tickAmount; if (nativeAmount != null) return Math.max(0, Math.round(nativeAmount)); } const attackPower = nativeStats ? kind === "spell" ? nativeStats.spellPower : kind === "ranged" ? nativeStats.rangedAttackPower : nativeStats.attackPower : equipmentAttackPower(classId, gearStats, kind); const channel = ability.wowSpellDefense ? enemyDisarmAbilityWeaponChannel({ ...ability.wowSpellDefense, equippedItemSubclassMask: ability.wowWeaponRequirements?.equippedItemSubclassMask, }) : undefined; const originalWeapon = kind === "ranged" ? gearStats.rangedWeapon : kind === "melee" ? gearStats.meleeWeapon : null; const weapon = channel && disabledWeaponChannels ? enemyDisarmAdjustedAbilityWeapon(originalWeapon, channel, disabledWeaponChannels) : originalWeapon; const selectedWeaponDisabled = channel !== undefined && channel !== "main-hand" && disabledWeaponChannels?.has(channel); const weaponSpeed = selectedWeaponDisabled ? 0 : weapon?.speedMs ? weapon.speedMs / 1_000 : kind === "melee" ? 2.4 : 2.8; const weaponDamage = weapon ? (weapon.damageMin + weapon.damageMax) / 2 : 0; const equipmentPower = kind === "spell" ? attackPower + (nativeStats ? 0 : Math.max(0, holyBlessingSpellPowerBonus)) : attackPower / 14 * weaponSpeed + weaponDamage * 0.35; const sourceLevel = Math.max(1, typeof scaling === "number" ? level : scaling.sourceLevel ?? level); const scalingLevel = Math.min(level, typeof scaling === "number" ? level : scaling.maxScalingLevel || level); const coefficient = typeof scaling === "number" ? scaling : scaling.coefficient + Math.max(0, scalingLevel - sourceLevel) * Math.max(0, scaling.coefficientPerLevel ?? 0); if (typeof scaling === "number") { return Math.max( 0, Math.round(coefficient * (playerCombatPower(level) + equipmentPower)), ); } const attributes = scaling.attackPowerCoefficient || scaling.rangedAttackPowerCoefficient || scaling.intellectCoefficient || scaling.spiritCoefficient || scaling.staminaCoefficient ? nativeStats ?? deriveCharacterStats(classId, level, gearStats, raceId, {}, secondaryClassId, gender, statRuleset) : null; const mainHand = channel && disabledWeaponChannels ? enemyDisarmAdjustedAbilityWeapon(gearStats.meleeWeapon, channel, disabledWeaponChannels) : gearStats.meleeWeapon; const mainHandDps = mainHand && mainHand.speedMs > 0 ? ((mainHand.damageMin + mainHand.damageMax) / 2) / (mainHand.speedMs / 1_000) : 0; const scriptedPower = (attributes?.attackPower ?? 0) * (scaling.attackPowerCoefficient ?? 0) + (attributes?.rangedAttackPower ?? 0) * (scaling.rangedAttackPowerCoefficient ?? 0) + (intellectOverride ?? attributes?.intellect ?? 0) * (scaling.intellectCoefficient ?? 0) + (attributes?.spirit ?? 0) * (scaling.spiritCoefficient ?? 0) + (attributes?.stamina ?? 0) * (scaling.staminaCoefficient ?? 0) + mainHandDps * (scaling.weaponDpsCoefficient ?? 0); if (scaling.basePoints === undefined) { // Preserve the historical coefficient-only result when no explicit // bonusPowerCoefficient is supplied. Mage Empowered Fire sets that field // to carry a spell-power-only increase without changing base damage. const gearCoefficient = scaling.bonusPowerCoefficient ?? coefficient; return Math.max(0, Math.round( coefficient * playerCombatPower(level) + gearCoefficient * equipmentPower + scriptedPower, )); } const levelGrowth = Math.max(0, scalingLevel - sourceLevel) * Math.max(0, scaling.pointsPerLevel ?? 0); const averageRoll = Math.abs(scaling.basePoints) + Math.max(0, Math.abs(scaling.dieSides ?? 1) - 1) / 2 + levelGrowth; // Starter-kit spells are granted early, but their raw DBC values were tuned // for their original client level. Scale that base down to the player's // current combat budget; once the source level is reached, use the DBC value. const earlyLevelScale = level < sourceLevel ? playerCombatPower(level) / playerCombatPower(sourceLevel) : 1; const bonusPower = equipmentPower * Math.max( 0, scaling.bonusPowerCoefficient ?? coefficient, ); return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower + scriptedPower)); } function playerCharacterStats( classId: ClassId, level: number, gearStats: ItemStats, raceId: RaceId, secondaryClassId: ClassId | null, modifiers: readonly TalentModifier[], gender: GenderId = "male", statRuleset: CharacterStatRuleset = "wow", ): CharacterStats { const attributePercent = (attribute: "agility" | "intellect" | "spirit" | "stamina" | "strength") => ( applyTalentModifierValue( 100, modifiers.filter((modifier) => modifier.kind !== "attribute-percent" || modifier.attribute === attribute), "attribute-percent", ) - 100 ); const armorPercent = applyTalentModifierValue(100, modifiers, "armor-percent") - 100; const stats = deriveCharacterStats(classId, level, gearStats, raceId, { agility: attributePercent("agility"), intellect: attributePercent("intellect"), spirit: attributePercent("spirit"), stamina: attributePercent("stamina"), strength: attributePercent("strength"), }, secondaryClassId, gender, statRuleset); if (statRuleset === "rom" && stats.romNative && armorPercent !== 0) { const native = applyRomNativeStatModifiers(stats.romNative, { rates: { physicalDefense: Math.max(0, 1 + armorPercent / 100) }, }); return Object.freeze({ ...stats, armor: native.physicalDefense, armorReductionPercent: armorReductionPercent(native.physicalDefense, stats.level), romNative: native, }); } return armorPercent === 0 ? stats : Object.freeze({ ...stats, armor: Math.max(0, Math.round(stats.armor * (1 + armorPercent / 100))), }); } function paladinShieldEquipped(state: Pick): boolean { if (state.classId !== "paladin") return false; return resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, })["off-hand"]?.armorType === "shield"; } function paladinTalentRankDescription(talentRanks: TalentRanks, talentName: string): string | undefined { let selectedRank = 0; let selectedDescription: string | undefined; for (const [nodeId, points] of Object.entries(talentRanks)) { const node = talentNodeById(nodeId); const rank = Math.max(0, Math.trunc(points)); if (node?.name !== talentName || rank <= selectedRank || rank > node.maxRank) continue; selectedRank = rank; selectedDescription = node.rankDescriptions[rank - 1]; } return selectedDescription; } function paladinRedoubtBlockStats( baseStats: CharacterStats, classId: ClassId, talentRanks: TalentRanks, hasShield: boolean, ): CharacterStats { if (classId !== "paladin" || !hasShield) return baseStats; const profile = wow335PaladinRedoubtProfile(paladinTalentRankDescription(talentRanks, "Redoubt")); return profile ? { ...baseStats, blockPercent: baseStats.blockPercent + profile.blockChancePercent } : baseStats; } function npcCombatDefender( mob: MobCombatState, attackerLevel: number, auras: readonly ActiveAura[], sourceActorId: string, now: number, attackKind: WotlkAttackKind, isAutoAttack: boolean, spellDefense?: EnemyNpcSpellDefenseMetadata, ): CombatDefenderStats { const levelAdvantage = Math.max(0, mob.level - attackerLevel); // Ordinary source Creature objects have STR=0. Guardians and native RoM // creatures have separate stat models and must not inherit that baseline. const template = (isAutoAttack || spellDefense) && (attackKind === "melee" || attackKind === "ranged") && mob.runewakerNpcId === undefined && !mob.summonedBy && mob.serverEntry ? creatureTemplateReference(mob.serverEntry) : null; const blockAuraInputs = resolveEnemyNpcBlockAuraInputs({ auras, targetId: mob.id, now }); const block = template?.flagsExtra === undefined || !blockAuraInputs ? null : resolveEnemyNpcBlockStats({ level: mob.level, isTotem: false, blockChanceAuraPercent: blockAuraInputs.blockChanceAuraPercent, }); const targetTransform = block ? getMobRuntimeTransform(mob.id) : null; const targetSpatial = targetTransform ? { ...targetTransform, id: mob.id } : block ? combatSpatialActor(mob.id) : null; const sourceSpatial = block ? combatSpatialActor(sourceActorId) : null; const blockGates = block && targetSpatial && sourceSpatial ? { attack: (isAutoAttack && attackKind === "melee" ? "melee-auto" : isAutoAttack && attackKind === "ranged" ? "ranged-auto" : "spell") as EnemyNpcBlockAttack, ...block, flagsExtra: template!.flagsExtra!, isTotem: false, attackerLevel, defenderLevel: mob.level, attackerIsBehind: !isPositionInFacingArc(targetSpatial, sourceSpatial.position, Math.PI), ignoresHitDirection: blockAuraInputs?.ignoresHitDirection ?? false, isCastingNonMeleeSpell: Boolean(mob.activeCast && mob.activeCast.completesAt > now), // Core CONTROLLED is stun/fear/confuse. Other incapacitation labels below // map HealerMan's unable-to-defend abstraction; a plain root can still block. isControlled: mob.statuses.some((status) => status.endsAt > now && ["stun", "fear", "confuse", "sleep", "blind", "knockdown", "freeze"].includes(status.kind)), isEvadingAttacks: mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now, } : null; const blockEligible = blockGates && (spellDefense ? !blockGates.isEvadingAttacks && canEnemyNpcBlockSpell({ ...blockGates, spell: spellDefense, attackerWeaponSkill: attackerLevel * 5, }) : canEnemyNpcBlock(blockGates)); const resistance = (value: number, school: DamageSchool): number => Math.max(0, resolveAuraStatValue(resolveAuraStatValue(value, auras, `resistance:${school}`), auras, "magical-defense"), ); return { level: mob.level, armor: Math.max(0, resolveAuraStatValue(mob.armor, auras, "armor")), dodgePercent: 5 + levelAdvantage * 0.5, parryPercent: 5 + levelAdvantage * 0.5, blockPercent: blockEligible && block ? (spellDefense ? enemyNpcSpellBlockChanceAfterSkill({ phase: classifyEnemyNpcSpellBlockPhase(spellDefense) === "hit-table" ? "hit-table" : "direct-damage", blockPercent: block.blockPercent, attackerWeaponSkill: attackerLevel * 5, defenderLevel: mob.level, }) : enemyNpcBlockChanceAfterSkill({ blockPercent: block.blockPercent, attackerLevel, defenderLevel: mob.level, })) ?? 0 : 0, blockValue: blockEligible && block ? block.blockValue : 0, ...(blockEligible && blockAuraInputs ? { criticalBlockChancePercent: blockAuraInputs.criticalBlockChancePercent } : {}), resistances: { arcane: resistance(mob.resistances.arcane, "arcane"), fire: resistance(mob.resistances.fire, "fire"), frost: resistance(mob.resistances.frost, "frost"), nature: resistance(mob.resistances.nature, "nature"), shadow: resistance(mob.resistances.shadow, "shadow"), }, }; } function stableCombatRoll(seed: string) { return (stage: CombatRollStage) => stableCombatUnit(`${seed}:${stage}`); } function resolveNpcUtilityBlockResult( state: Pick, targetId: string, sourceActorId: string, sourceStats: CharacterStats, ability: AbilityDefinition, now: number, ): CombatResult | null { const mob = state.mobs[targetId]; const raw = ability.wowSpellDefense; const contract = enemyUtilitySpellBlockAbilityContract(ability); const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); const casterKind = sourceActorId === PLAYER_AGGRO_ID ? "player" : member ? "party" : "unknown"; if (!contract || !raw || ability.source === "runewaker" || sourceStats.romNative || ability.target !== "hostile" || !mob || mob.dead || !supportsEnemyCreatureWeaponAttacks(mob) || creatureTemplateReference(mob.serverEntry!) === null) return null; // Add only the reviewed missing-bit evidence; explicit overrides must agree. const spell = raw.customAttributes !== undefined || raw.customAttributeEvidence !== undefined ? raw : { ...raw, customAttributeEvidence: contract.spellDefense.customAttributeEvidence }; if (enemyUtilitySpellBlockDisposition({ spellId: ability.dbcSpellId, sourceProfile: raw.sourceProfile, spellDefense: spell, casterKind, targetKind: "ordinary-npc", outcome: "hit" }) === "fallback") return null; const defender = npcCombatDefender(mob, sourceStats.level, aurasForEntity(state.auras, targetId, now), sourceActorId, now, "melee", false, spell); return resolveCombatResult({ amount: 0, attackKind: "melee", damageSchool: "physical", attacker: combatAttackerFromCharacterStats(sourceStats, "melee"), // A full utility block cancels effects before damage; it has no critical-block roll. defender: { ...defender, criticalBlockChancePercent: undefined }, // Other utility hit/avoidance behavior stays on the existing application path. rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: true }, roll: stableCombatRoll(`${sourceActorId}:${ability.id}:${targetId}:${now}:utility-hit`), }); } function utilitySpellHitEvent( result: CombatResult, targetId: string, sourceActorId: string, ability: AbilityDefinition, now: number, ): CombatEventInput { return { occurredAt: now, kind: "cast", sourceActorId, targetActorId: targetId, abilityId: ability.id, school: "physical", rawAmount: 0, effectiveAmount: 0, overheal: 0, absorbed: 0, critical: false, outcome: result.outcome, blocked: result.blocked, blockedAmount: 0 }; } function nativeCombatRolls(seed: string) { // A MagicCollect target shares one hit gate across its direct and buff components. const hitSeed = seed.replace(/:(?:direct|special-crit|dot-application)(?::(?:main-hand|off-hand))?$/, ""); return { dodge: stableCombatUnit(`${hitSeed}:native:dodge`), miss: stableCombatUnit(`${hitSeed}:native:miss`), block: stableCombatUnit(`${seed}:native:block`), parry: stableCombatUnit(`${seed}:native:parry`), critical: stableCombatUnit(`${seed}:native:critical`), hit: stableCombatUnit(`${hitSeed}:native:hit`), }; } function nativePlayerCombatant(stats: RomNativeDerivedStats | undefined, slot: "main-hand" | "off-hand" | "ranged" = "main-hand") { if (!stats) return undefined; const result = romNativeTrainingCombatant(stats, matchesNativeTrainingGear(stats, stats.sourceInputs.level), slot); return result.status === "supported" ? result.value : undefined; } function nativeMobCombatant(mob: MobCombatState, auras: readonly ActiveAura[] = []): RomNativeCombatantSnapshot | undefined { const native = mob.runewakerNpcId ? romNativeNpcCombatant(mob.runewakerNpcId, mob.level)?.nativeCombatant : undefined; if (!native) return undefined; return { ...native, physicalDefense: Math.max(0, resolveAuraStatValue(resolveAuraStatValue(native.physicalDefense, auras, "armor"), auras, "physical-defense")), magicalDefense: Math.max(0, resolveAuraStatValue(native.magicalDefense, auras, "magical-defense")), }; } const NATIVE_ENEMY_NON_ABSORB_STATS = new Set(["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana", "max-health", "max-mana", "physical-attack", "physical-defense", "magical-attack", "magical-defense", "armor", "attack-power", "spell-power", "heal-power", "heal-point", "parry", "dexterity", "ranged-haste", "melee-haste", "spell-haste", "physical-hit", "magical-hit", "dodge", "physical-critical-strike", "magical-critical-strike", "ranged-hit", "attack-speed", "resistance:fire", "resistance:frost", "resistance:nature", "resistance:shadow", "resistance:holy", "resistance:arcane", "source-modifier-88", "source-modifier-95", "source-modifier-96", "source-modifier-98", "source-modifier-106"]); function nativeEnemyAbsorbAurasVerifiedNeutral(auras: readonly ActiveAura[]): boolean { // Equipment-enable channels 88/95/96/98/106 do not alter native absorption. // Unrepresented source absorb/AoE channels cannot inherit neutral inputs. return auras.every((aura) => !/(?:magic-damage-absorption|physical-damage-absorption|area-magic-damage-taken|source-modifier-(?:142|143|145|146|209))(?:-percent)?$/.test(aura.definition.name) && (aura.definition.modifiers ?? []).every((modifier) => modifier.kind !== "stat" || NATIVE_ENEMY_NON_ABSORB_STATS.has(modifier.stat))); } function nativeEnemyHitProfile(mob: MobCombatState, attack: EnemyAttackDefinition): RomNativeHitProfile | undefined { if (!attack.spellId) return attack.animation === "attack" ? { kind: "auto", effectType: 1, damageChannel: "physical", hitRateFunc: 0, hitRateArgs: [0, 0] } : undefined; const binding = mob.runewakerNpcId ? romNativeNpcCombatant(mob.runewakerNpcId, mob.level)?.spellBindings .find((entry) => entry.sourceSkillId === attack.spellId) : undefined; if (!binding || ![0, 1].includes(binding.magicCollect.effectType) || ![0, 1, 2, 3, 4].includes(binding.magicCollect.hitRateFunc)) return undefined; const componentId = attack.runewakerComponentId ?? attack.runewakerFormula?.componentId; return mob.runewakerNpcId && componentId ? romNativeNpcHitProfile(mob.runewakerNpcId, attack.spellId, componentId) : undefined; } function nativeOutgoingPeriodicSnapshot( scaling: AbilityAmountScaling, caster: RomNativeDerivedStats | undefined, mob: MobCombatState | undefined, auras: readonly ActiveAura[], seed: string, sourceActorId: string = PLAYER_AURA_ENTITY_ID, now = combatNow(), ) { if (!caster || !mob || scaling.romNativeSource?.kind !== "dot") return undefined; const source = romNativePeriodicDamageBinding(scaling.romNativeSource); const casterCombatant = nativePlayerCombatant(caster); const targetProfile = mob.runewakerNpcId ? romNativeNpcCombatant(mob.runewakerNpcId, mob.level) : undefined; const targetCombatant = nativeMobCombatant(mob, aurasForEntity(auras, mob.id)); if (!source || !casterCombatant || !targetProfile || !targetCombatant) return undefined; const casterMagic = nativeMagicInputsForType(casterCombatant, source.component.magicType); const targetMagic = nativeMagicInputsForType(targetCombatant, source.component.magicType); const snapshot = nativePeriodicDamageCasterSnapshot(scaling, caster.sourceInputs.level, caster, { ...targetProfile.periodicStats, health: { max: mob.maxHealth }, physicalDefense: targetCombatant.physicalDefense, magicalDefense: targetCombatant.magicalDefense }, mob.level, targetMagic?.magicResistance, casterMagic?.effectiveMagicPower, activeEnemyDisarmChannels(auras, sourceActorId, now).has("main-hand") ? nativeDisarmedMainHandAutoAttackProfile(caster) : undefined); if (!snapshot) return undefined; const profile = romNativePlayerHitProfile(source.sourceSkillId, source.component.componentId); if (profile) { const result = resolveRomNativeCombatResult({ ruleset: "rom", amount: 0, attackKind: profile.effectType === 1 ? "melee" : "spell", damageSchool: "physical", damageChannel: "none", attacker: casterCombatant, defender: targetCombatant, hitProfile: profile, rules: { canCrit: false, canBlock: false, canParry: false }, rolls: nativeCombatRolls(seed) }); if (result.status === "resolved" && !result.combatResult.landed) return null; } return snapshot; } function nativeIncomingPeriodicSnapshot( mob: MobCombatState, attack: EnemyAttackDefinition, effect: Extract, targetId: AggroActorId, state: CombatState, auras: readonly ActiveAura[], now: number, attackRevision: number, ) { if (!mob.runewakerNpcId || !attack.spellId || !effect.componentId) return undefined; const source = romNativePeriodicDamageBinding({ spellId: attack.spellId, componentId: effect.componentId, npcId: mob.runewakerNpcId, kind: "dot" }); const caster = romNativeNpcCombatant(mob.runewakerNpcId, mob.level); const target = nativeHealingTargetStats(targetId, state, state.level, auras, now); const targetCombatant = nativePlayerCombatant(target); if (!source || !caster?.nativeCombatant || !target || !targetCombatant) return undefined; const binding = caster.spellBindings.find((entry) => entry.sourceSkillId === source.sourceSkillId); if (!binding) return undefined; const casterMagic = nativeMagicInputsForType(caster.nativeCombatant, source.component.magicType); const targetMagic = nativeMagicInputsForType(targetCombatant, source.component.magicType); const snapshot = nativePeriodicDamageSnapshot({ source, caster: caster.periodicStats, target: { ...target, health: { max: targetId === PLAYER_AGGRO_ID ? state.maxHealth : state.actors[targetId]?.maxHealth ?? target.health.max } }, sourceIsPlayer: false, magicLevel: binding.magicLevel, casterLevel: mob.level, targetLevel: target.sourceInputs.level, mainDamage: caster.mainDamage, mainAttackSpeed: caster.mainAttackSpeed, dotMagicalDamageCoefficient: caster.dotMagicalDamageCoefficient, effectiveMagicPower: casterMagic?.effectiveMagicPower, magicResistance: targetMagic?.magicResistance }); if (snapshot.status !== "supported") return undefined; const hitProfile = nativeEnemyHitProfile(mob, attack); if (hitProfile) { const hit = resolveRomNativeCombatResult({ ruleset: "rom", amount: 0, attackKind: enemyAttackKind(attack), damageSchool: attack.school, damageChannel: "none", attacker: caster.nativeCombatant, defender: targetCombatant, hitProfile, rules: { canCrit: false, canParry: false, canBlock: false }, rolls: nativeCombatRolls(`${mob.id}:${attack.id}:${targetId}:${attackRevision}:${now}`) }); if (hit.status === "resolved" && !hit.combatResult.landed) return null; } return snapshot.value; } function shamanElementalOathCriticalChancePercent( classId: ClassId, talentRanks: TalentRanks, attackKind: WotlkAttackKind, ): number { if (classId !== "shaman" || attackKind !== "spell") return 0; return Math.min(3, Math.max(0, Math.trunc(talentRanks["talent-2049"] ?? 0))); } function resolvePlayerDamageResult( classId: ClassId, work: MutableCastWork, sourceActorId: string, now: number, mob: MobCombatState, amount: number, attackKind: WotlkAttackKind, school: DamageSchool, seed: string, ability?: AbilityDefinition, periodic = false, rules: CombatResultRules = {}, criticalChanceBonusPercent = 0, armorPenetrationBonusPercent = 0, selectedDbcSpellId?: number, ): CombatResult { const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const sourceAuras = aurasForEntity(work.auras, sourceActorId); const predatoryInstinctsRank = sourceMember?.classId === "druid" ? sourceMember.talentRanks[WOW335_DRUID_PREDATORY_INSTINCTS_TALENT_ID] ?? 0 : !sourceMember && classId === "druid" && sourceActorId === PLAYER_AGGRO_ID ? work.talentRanks[WOW335_DRUID_PREDATORY_INSTINCTS_TALENT_ID] ?? 0 : 0; const predatoryInstinctsForm = predatoryInstinctsRank > 0 ? wow335DruidShapeshiftFormForAuras(work.auras, sourceActorId, now) : null; const baseStats = sourceMember ? deriveCharacterStats( sourceMember.classId, sourceMember.level, sourceMember.gearStats, sourceMember.raceId, {}, sourceMember.secondaryClassId, sourceMember.gender, sourceMember.statRuleset ?? "wow", ) : playerCharacterStats( classId, work.progression.level, work.gearStats, work.raceId, work.secondaryClassId, work.talentModifiers, work.gender, work.statRuleset, ); const stats = applyAuraStats(baseStats, sourceAuras); const baseAttacker = combatAttackerFromCharacterStats(stats, attackKind); const dayOfRainMissChance = romRogueMageDayOfRainBaseMissPercent( classId, work.secondaryClassId, ability?.dbcSpellId, sourceAuras.flatMap((aura) => aura.definition.utilityTags ?? []), baseAttacker.hitPercent ?? 0, ); const resolvedRules = dayOfRainMissChance === null ? rules : { ...rules, baseMissChancePercent: dayOfRainMissChance }; const deathchillAbilities = ["Icy Touch", "Howling Blast", "Frost Strike", "Obliterate"]; const deathchillEligible = classId === "death-knight" && Boolean(ability) && deathchillAbilities.includes(ability!.name); const nextCritical = !periodic && Boolean(ability && sourceAuras.some((aura) => ( aura.definition.utilityTags?.includes("next-offensive-critical") && (!aura.definition.utilityTags.includes("wow335-deathchill-next-critical") || deathchillEligible) ))); const sourceShamanTalentRank = sourceMember?.classId === "shaman" ? sourceMember.talentRanks[WOW335_SHAMAN_NATURES_GUARDIAN_TALENT_ID] ?? 0 : !sourceMember && classId === "shaman" && sourceActorId === PLAYER_AGGRO_ID ? work.talentRanks[WOW335_SHAMAN_NATURES_GUARDIAN_TALENT_ID] ?? 0 : 0; const nextNatureSpellCritical = sourceShamanTalentRank > 0 && !periodic && attackKind === "spell" && school === "nature" && Boolean(ability?.effects.some((effect) => effect.kind === "damage" && (effect.school ?? abilityDamageSchool(sourceMember?.classId ?? classId, ability!)) === "nature")) && wow335ShamanNaturesGuardianNextCritIsReady(work.auras, sourceActorId, now); const premeditation = nextCritical && sourceAuras.some((aura) => ( aura.definition.utilityTags?.includes("rom-premeditation") )); const criticalChanceFromTalents = sourceMember ? 0 : ability ? applyTalentModifierValue( 0, work.talentModifiers, "critical-chance-percent", abilityTalentContext(ability, periodic), ) : applyTalentModifierValue(0, work.talentModifiers, "critical-chance-percent"); const partyMageWintersChillPassiveCriticalBonus = sourceMember?.classId === "mage" && ability ? wow335MageWintersChillProfileForRank( mageTalentRank(sourceMember.talentRanks, "Winter's Chill", 3), ).casterCriticalChanceBonusPercent : 0; const improvedVictoryRushRank = sourceMember?.classId === "warrior" ? sourceMember.talentRanks[WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_TALENT_ID] ?? 0 : !sourceMember && classId === "warrior" ? work.talentRanks[WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_TALENT_ID] ?? 0 : 0; const improvedVictoryRushCriticalBonus = ability ? wow335WarriorImprovedVictoryRushCriticalChanceBonusPercent( improvedVictoryRushRank, ability.name, ) : 0; // Elemental Oath's owner and party clauses describe the same rank bonus. // This shared spell-only hook covers the Shaman and party spell casters // exactly once without affecting melee/ranged attacks. const isPlayerOrPartySource = sourceActorId === PLAYER_AGGRO_ID || Boolean(sourceMember) || onlinePlayerRuntimeActors().some((actor) => actor.id === sourceActorId); const elementalOathBonus = isPlayerOrPartySource ? shamanElementalOathCriticalChancePercent(classId, work.talentRanks, attackKind) : 0; const spreadingPainActive = sourceMember?.classId === "rom-warlock" && sourceMember.secondaryClassId === "rom-priest" ? sourceAuras.some((aura) => ( aura.definition.utilityTags?.includes(ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )) : !sourceMember && classId === "rom-warlock" && work.secondaryClassId === "rom-priest" ? sourceAuras.some((aura) => ( aura.definition.utilityTags?.includes(ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )) : false; // The Scout/Priest pair gate belongs to applying the aura. Every modeled // party member receiving that aura benefits from its critical-damage bonus. const drumsOfWarActive = sourceAuras.some((aura) => ( aura.definition.utilityTags?.includes(ROM_SCOUT_PRIEST_DRUMS_OF_WAR_CRITICAL_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )); const baseCriticalDamageMultiplier = wow335DruidPredatoryInstinctsCriticalDamageMultiplier(sourceMember ? 2 : applyTalentModifierValue( 2, work.talentModifiers, "critical-damage-percent", ability ? abilityTalentContext(ability, periodic) : {}, ), predatoryInstinctsRank, predatoryInstinctsForm) * (premeditation ? 1.4 : 1); const deathGraspRank = classId === "warlock" && sourceActorId === PLAYER_AGGRO_ID ? warlockTalentRank(work.talentRanks, "Death's Grasp", 3) : 0; const warlockSpellHitBonus = warlockDeathGraspHitChanceBonusForRank(deathGraspRank, { sourceIsWarlockPlayer: classId === "warlock" && sourceActorId === PLAYER_AGGRO_ID, hasAbility: Boolean(ability), periodic, }); const attacker: CombatAttackerStats = { ...baseAttacker, armorPenetrationPercent: (baseAttacker.armorPenetrationPercent ?? 0) + armorPenetrationBonusPercent, hitPercent: (baseAttacker.hitPercent ?? 0) + warlockSpellHitBonus, criticalChancePercent: (baseAttacker.criticalChancePercent ?? 0) + criticalChanceFromTalents + partyMageWintersChillPassiveCriticalBonus + elementalOathBonus + criticalChanceBonusPercent + improvedVictoryRushCriticalBonus + (nextCritical || nextNatureSpellCritical ? 100 : 0), criticalDamageMultiplier: romScoutPriestDrumsOfWarCriticalMultiplier( baseCriticalDamageMultiplier, drumsOfWarActive, spreadingPainActive && attackKind === "spell" && school !== "physical" ? ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_DAMAGE_BONUS : 0, ), }; const immune = mob.schoolImmunities.includes(school); const nativeAttacker = nativePlayerCombatant(stats.romNative, attackKind === "ranged" ? "ranged" : seed.includes("offhand") ? "off-hand" : "main-hand"); const nativeDefender = nativeMobCombatant(mob, aurasForEntity(work.auras, mob.id)); const sourceEffect = ability?.effects.find((effect) => (effect.kind === "damage" || effect.kind === "dot") && effect.romNativeSource && (periodic ? effect.romNativeSource.kind === "dot" : effect.romNativeSource.kind === "damage") && (!("school" in effect) || !effect.school || effect.school === school)); const nativeSource = resolvedRules.romNativeSource ?? (sourceEffect && "romNativeSource" in sourceEffect ? sourceEffect.romNativeSource : undefined); const sourceProfile = nativeSource ? romNativePlayerHitProfile(nativeSource.skillId, nativeSource.componentId) : !ability && attackKind !== "spell" ? { kind: "auto" as const, effectType: 1 as const, damageChannel: "physical" as const, hitRateFunc: 0 as const, hitRateArgs: [0, 0] as const } : undefined; if (nativeAttacker && nativeDefender && (sourceProfile?.damageChannel !== "none" && sourceProfile || resolvedRules.targetMitigationSnapshotted)) { const hitProfile: RomNativeHitProfile = { ...(sourceProfile ?? { kind: "magic", effectType: 0, hitRateFunc: 0, hitRateArgs: [0, 0] }), targetOrdinal: resolvedRules.romNativeTargetOrdinal ?? 0, criticalRateBonus: (sourceProfile?.criticalRateBonus ?? 0) + criticalChanceBonusPercent + (nextCritical || nextNatureSpellCritical ? 100 : 0) }; const nativeResult = resolveRomNativeCombatResult({ ruleset: "rom", amount, attackKind, damageSchool: school, damageChannel: hitProfile.damageChannel ?? "none", attacker: nativeAttacker, defender: nativeDefender, hitProfile, rules: immune ? { ...resolvedRules, immune: true } : resolvedRules, targetMitigationSnapshotted: resolvedRules.targetMitigationSnapshotted, rolls: nativeCombatRolls(seed) }); if (nativeResult.status === "resolved") return nativeResult.combatResult; } // Source direct physical damage has a second, independent block roll. A // known raw effect proves CU_DIRECT_DAMAGE without assuming absent SQL flags. // Unbound, periodic, native, and non-direct utility packets stay outside it. const rankSpellDefense = selectedDbcSpellId === undefined || selectedDbcSpellId === ability?.dbcSpellId ? ability?.wowSpellDefense : ability?.ranks?.find((rank) => rank.spellId === selectedDbcSpellId)?.wowSpellDefense; const spellDefense = !periodic && !resolvedRules.isAutoAttack && !mob.characterDefender && rankSpellDefense && school === "physical" && (rankSpellDefense.damageClass === 2 && attackKind === "melee" || rankSpellDefense.damageClass === 3 && attackKind === "ranged") && classifyEnemyNpcSpellBlockPhase(rankSpellDefense) === "direct-damage" ? rankSpellDefense : undefined; const npcRules: CombatResultRules = spellDefense ? { ...resolvedRules, blockMode: "post-hit" } : resolvedRules; return resolveCombatResult({ amount, attackKind, damageSchool: school, attacker, defender: mob.characterDefender ?? npcCombatDefender(mob, stats.level, aurasForEntity(work.auras, mob.id).filter((aura) => aura.expiresAt === null || aura.expiresAt > now), sourceActorId, now, attackKind, !periodic && Boolean(resolvedRules.isAutoAttack), spellDefense), rules: immune ? { ...npcRules, immune: true, canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false, resistanceMode: "binary", } : npcRules, roll: stableCombatRoll(seed), }); } function combatResultDescription(result: CombatResult): string { if (result.outcome === "miss") return "missed"; if (result.outcome === "dodge") return "was dodged"; if (result.outcome === "parry") return "was parried"; if (result.outcome === "resist") return "was resisted"; if (result.outcome === "deflect") return "was deflected"; if (result.outcome === "block") return `was blocked for ${result.blockedAmount}`; if (result.outcome === "crit") return `critically hit for ${result.finalDamage}` + (result.blocked ? ` (${result.blockedAmount} blocked)` : ""); if (result.partiallyResisted) return `hit for ${result.finalDamage} (${result.resistedAmount} resisted)`; return `hit for ${result.finalDamage}`; } function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind { if (attack.attackKind) return attack.attackKind; if (attack.damageClass === 1) return "spell"; if (attack.damageClass === 2) return "melee"; if (attack.damageClass === 3) return "ranged"; if (attack.runewakerEffectType === 0) return "spell"; if (attack.delivery === "melee") return "melee"; if (attack.delivery === "projectile" && attack.school === "physical") return "ranged"; return "spell"; } const WARLOCK_SOUL_LEECH_AP_DEBUFF_AURA_ID = "wow335:warlock:soul-leech-ap-debuff"; const WARLOCK_SOUL_LEECH_AP_DEBUFF_AMOUNT = 2; const WARLOCK_SOUL_LEECH_AP_DEBUFF_DURATION_MS = 10_000; function enemyAttackDamageAfterVindication( mob: MobCombatState, attack: EnemyAttackDefinition, amount: number, effectMultiplier: number, now: number, auras: readonly ActiveAura[] = useCombatStore.getState().auras, ): number { if (enemyAttackKind(attack) !== "melee" || mob.attackPower <= 0 || amount <= 0) return amount; const auraAttackPower = Math.max(0, resolveAuraStatValue(mob.attackPower, aurasForEntity(auras, mob.id, now), "attack-power")); const auraContributionDelta = Math.max(0, mob.attackPowerDamageContribution) * (auraAttackPower / mob.attackPower - 1) * Math.max(0, effectMultiplier); amount = Math.max(0, Math.round(amount + auraContributionDelta)); const vindication = mob.auras.find((aura) => ( aura.id === PALADIN_VINDICATION_AURA_ID && (aura.endsAt <= 0 || aura.endsAt > now) )); const customReduction = vindication ? Math.max(0, Number.isFinite(vindication.magnitude) ? vindication.magnitude! : 0) : 0; const auraReduction = mob.auras .filter((aura) => (aura.id.startsWith(WOW335_WARLOCK_IMMOLATION_MOB_AURA_PREFIX) || aura.id === WARLOCK_SOUL_LEECH_AP_DEBUFF_AURA_ID) && (aura.endsAt <= 0 || aura.endsAt > now)) .reduce((maximum, aura) => Math.max(maximum, Math.max(0, aura.magnitude ?? 0)), 0); const reducedAttackPower = Math.min(mob.attackPower, Math.max(customReduction, auraReduction)); if (reducedAttackPower <= 0) return amount; const apDamageReduction = Math.max(0, mob.attackPowerDamageContribution) * reducedAttackPower / mob.attackPower * Math.max(0, effectMultiplier); return Math.max(0, Math.round(amount - apDamageReduction)); } function enemyDamageAfterTargetAuras( amount: number, auras: readonly ActiveAura[], mobId: string, attack: EnemyAttackDefinition, now: number, ): number { if (attack.periodicTick && attack.outgoingDamageSnapshotted) return amount; const attackKind = enemyAttackKind(attack); const outgoingAuras = aurasForEntity(auras, mobId).filter((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && (attackKind === "melee" || !aura.definition.utilityTags?.includes(ROM_WARDEN_WILL_ATTACK_TAG)) )); if (!outgoingAuras.length) return amount; return Math.round(resolveAuraDamageValue(amount, outgoingAuras, { direction: "dealt", school: attack.school, attackKind, })); } function isEnemyMainHandAutoAttack(attack: EnemyAttackDefinition): boolean { const mainHand = attack.attackHand === undefined ? attack.id === DEFAULT_ENEMY_ATTACK.id : attack.attackHand === "mainhand"; return mainHand && attack.delivery === "melee" && attack.animation === "attack" && (attack.castTimeMs ?? 0) === 0; } function isEnemyWeaponSwing(attack: EnemyAttackDefinition): boolean { return isEnemyMainHandAutoAttack(attack) || attack.attackHand === "offhand" || attack.attackHand === "extra-mainhand"; } function enemyWeaponEventDetails( mob: MobCombatState, attack: EnemyAttackDefinition, ): Pick { if (attack.periodicTick) return {}; const attackHand = attack.attackHand ?? (supportsEnemyCreatureWeaponAttacks(mob) && isEnemyMainHandAutoAttack(attack) ? "mainhand" : undefined); return attackHand ? { attackHand, ...(attackHand === "extra-mainhand" && attack.spellId !== undefined ? { extraAttackSourceSpellId: attack.spellId } : {}), } : {}; } function supportsEnemyCreatureWeaponAttacks(mob: MobCombatState): boolean { return mob.serverEntry !== null && mob.runewakerNpcId === undefined && mob.summonedBy === undefined && mob.characterDefender === undefined; } function enemyWeaponSwingAttack( id: string, attackHand: NonNullable, spellId?: number, damageMultiplier = 1, ): EnemyAttackDefinition { return { ...DEFAULT_ENEMY_ATTACK, id, name: attackHand === "offhand" ? "Offhand Attack" : spellId === 3391 ? "Thrash" : "Mainhand Attack", ...(spellId === undefined ? {} : { spellId }), delivery: "melee", target: "primary", school: "physical", attackKind: "melee", attackHand, animation: "attack", castTimeMs: 0, damageMultiplier, effect: { kind: "damage", multiplier: damageMultiplier }, }; } function resetEnemyWeaponAttackState(mob: MobCombatState): Pick { return { dualWieldEnabled: supportsEnemyCreatureWeaponAttacks(mob) && (enemyFlagDrivenOffhandContract(mob.serverEntry!) !== undefined || Boolean(mob.sourceEquipment?.autoOffhand)), nextEnemyOffhandAttackAt: 0, pendingExtraAttacks: [], lastDamagedActorId: null, }; } function enemyMeleeCriticalCharge( mob: MobCombatState, attack: EnemyAttackDefinition, now: number, ): MobAura | undefined { if (!isEnemyWeaponSwing(attack)) return undefined; return mob.auras.find((aura) => ( (aura.endsAt <= 0 || aura.endsAt > now) && (aura.charges ?? 0) > 0 && (aura.nextMeleeCriticalChancePercent ?? 0) > 0 )); } function advanceEnemyMeleeAutoProcAuras( mob: MobCombatState, attack: EnemyAttackDefinition, now: number, attackRevision: number, landed: boolean, consumedAuraId?: string, ): readonly MobAura[] { if (!landed || !isEnemyWeaponSwing(attack)) return mob.auras; let changed = false; let nextAuras = mob.auras.flatMap((aura): MobAura[] => { if (aura.id !== consumedAuraId) return [aura]; changed = true; const charges = Math.max(0, Math.round(aura.charges ?? 0) - 1); return charges > 0 ? [{ ...aura, charges }] : []; }); for (const driver of mob.auras) { const proc = driver.meleeAutoCriticalProc; if (!proc || (driver.endsAt > 0 && driver.endsAt <= now)) continue; const chance = clamp(Number.isFinite(proc.chance) ? proc.chance : 0, 0, 1); if ( chance <= 0 || stableCombatUnit( `${mob.id}:${driver.id}:${attack.id}:${attackRevision}:${now}:melee-auto-critical-proc`, ) >= chance ) { continue; } const triggeredAura = proc.triggeredAura.trim(); const criticalChancePercent = Math.max( 0, Number.isFinite(proc.criticalChancePercent) ? proc.criticalChancePercent : 0, ); const charges = Math.max(1, Math.round( Number.isFinite(proc.charges) ? proc.charges : 1, )); if (!triggeredAura || criticalChancePercent <= 0) continue; nextAuras = [ ...nextAuras.filter((aura) => aura.id !== triggeredAura), { id: triggeredAura, sourceAbilityId: driver.sourceAbilityId, endsAt: proc.durationMs > 0 ? now + Math.round(proc.durationMs) : 0, nextMeleeCriticalChancePercent: criticalChancePercent, charges, }, ]; changed = true; } return changed ? nextAuras : mob.auras; } function resolveEnemyDamageResult( mob: MobCombatState, attack: EnemyAttackDefinition, defender: CharacterStats, amount: number, actorId: AggroActorId, attackRevision: number, now: number, bonusCriticalChancePercent = 0, defenderAuras: readonly ActiveAura[] = [], nativeSourceDirect?: RomNativeEnemyDirectDamageValue, targetOrdinal = 0, applicationOnly = false, defenderStunnedAtImpact?: boolean, ): CombatResult { const attackKind = enemyAttackKind(attack); const currentState = useCombatStore.getState(); const deterrenceAbility = deterrenceAbilityForActor(currentState, actorId); const defenderStunned = defenderStunnedAtImpact ?? actorIsStunned(currentState, actorId, now); const deterrencePackage = deterrenceAbility && wow335DeterrenceActivePackage({ ability: deterrenceAbility, auras: currentState.auras, defenderId: actorId, nowMs: now, }); const hitSource = attack.damageOrigin === "wow335" && attack.spellId !== undefined ? enemyDamageTypeSourceForSpell("wow335", attack.spellId) : undefined; const sourceFlags = hitSource && "hitSource" in hitSource && hitSource.hitSource && attack.wowHitSource && hitSource.damageClass === attack.damageClass && hitSource.schoolMask === attack.schoolMask && hitSource.hitSource.attributes0 === attack.wowHitSource.attributes0 && hitSource.hitSource.attributes3 === attack.wowHitSource.attributes3 && hitSource.hitSource.attributes7 === attack.wowHitSource.attributes7 ? hitSource.hitSource : undefined; const sourceAlwaysHit = Boolean(sourceFlags && (sourceFlags.attributes3 & 0x00040000) !== 0); const sourceNoAttackMiss = Boolean(sourceFlags && [1, 2, 3].includes(attack.damageClass!) && (sourceFlags.attributes7 & 0x02000000) !== 0); const sourceNoActiveDefense = Boolean(sourceFlags && (attack.damageClass === 2 || attack.damageClass === 3) && (sourceFlags.attributes0 & 0x00200000) !== 0); const sourceCannotDodge = Boolean(sourceFlags && (sourceFlags.attributes7 & 0x00800000) !== 0); const sourceCannotParry = Boolean(sourceFlags && (sourceFlags.attributes7 & 0x01000000) !== 0); const incomingModifier = (stat: string) => defenderAuras.reduce((total, aura) => total + (aura.definition.modifiers ?? []).reduce((sum, modifier) => modifier.kind === "stat" && modifier.stat === stat && modifier.operation === "flat" ? sum + modifier.value * (modifier.perStack === false ? 1 : aura.stacks) : sum, 0), 0); const incomingHit = incomingModifier(`incoming-${attackKind}-hit`); const incomingCrit = incomingModifier("incoming-critical-strike") + incomingModifier(`incoming-${attackKind}-critical-strike`); const dualWieldWhiteMissPenalty = supportsEnemyCreatureWeaponAttacks(mob) && (mob.dualWieldEnabled ?? false) && isEnemyWeaponSwing(attack) ? ENEMY_DUAL_WIELD_WHITE_MISS_PENALTY_PERCENT : 0; const defenderStats = combatDefenderFromCharacterStats(defender); const nativeDefender = nativePlayerCombatant(defender.romNative); const nativeAttacker = nativeMobCombatant(mob); const baseHitProfile = nativeEnemyHitProfile(mob, attack); const hitProfile = baseHitProfile ? { ...baseHitProfile, targetOrdinal } : undefined; if (nativeSourceDirect && nativeDefender && nativeAttacker && hitProfile) { const sourceResult = resolveRomNativeCombatResult({ ruleset: "rom", amount, attackKind, damageSchool: attack.school, damageChannel: nativeSourceDirect.resolverDamageChannel, attacker: nativeAttacker, defender: nativeDefender, hitProfile: { ...hitProfile, damageChannel: nativeSourceDirect.resolverDamageChannel, ...(nativeSourceDirect.resolverCriticalKind ? { criticalKind: nativeSourceDirect.resolverCriticalKind } : {}), criticalRateBonus: (hitProfile.criticalRateBonus ?? 0) + Math.max(0, bonusCriticalChancePercent) + incomingCrit }, sourcePowerStagesAlreadyApplied: nativeSourceDirect.sourcePowerStagesAlreadyApplied, rules: { ...nativeSourceDirect.resolverRules, canMiss: true, canDodge: true }, rolls: nativeCombatRolls(`${mob.id}:${attack.id}:${actorId}:${attackRevision}:${now}`) }); if (sourceResult.status === "resolved") return sourceResult.combatResult; } let nativeFallbackGatePassed = false; // The collect's target gate is independent of its component damage formula. // Preserve that gate when only the amount/mitigation needs the shared adapter. if (nativeDefender && nativeAttacker && hitProfile && hitProfile.damageChannel === "none" && !attack.periodicTick && !attack.targetMitigationSnapshotted) { const gate = resolveRomNativeCombatResult({ ruleset: "rom", amount: 0, attackKind, damageSchool: attack.school, damageChannel: "none", attacker: nativeAttacker, defender: nativeDefender, hitProfile, rules: { canMiss: true, canDodge: true, canCrit: false, canParry: false, canBlock: false }, rolls: nativeCombatRolls(`${mob.id}:${attack.id}:${actorId}:${attackRevision}:${now}`) }); if (gate.status === "resolved") { if (gate.combatResult.outcome === "miss" || gate.combatResult.outcome === "dodge") { return { ...gate.combatResult, rawDamage: Math.max(0, Math.round(amount)) }; } nativeFallbackGatePassed = true; } } if (nativeDefender && nativeAttacker && hitProfile && hitProfile.damageChannel !== "none") { const nativeResult = resolveRomNativeCombatResult({ ruleset: "rom", amount, attackKind, damageSchool: attack.school, damageChannel: hitProfile.damageChannel ?? "none", attacker: nativeAttacker, defender: nativeDefender, hitProfile: { ...hitProfile, criticalRateBonus: (hitProfile.criticalRateBonus ?? 0) + Math.max(0, bonusCriticalChancePercent) + incomingCrit }, targetMitigationSnapshotted: attack.targetMitigationSnapshotted, rules: { canMiss: !attack.periodicTick, canCrit: !attack.periodicTick, canDodge: !attack.periodicTick, canParry: !attack.periodicTick, canBlock: !attack.periodicTick }, rolls: nativeCombatRolls(`${mob.id}:${attack.id}:${actorId}:${attackRevision}:${now}`) }); if (nativeResult.status === "resolved") return nativeResult.combatResult; } const preMitigationAmount = amount > 0 ? resolveAuraDamageValue(amount, defenderAuras, { direction: "taken", school: attack.school, attackKind, phase: "pre-mitigation", }) : amount; const result = resolveCombatResult({ amount: preMitigationAmount, attackKind, damageSchool: attack.school, attacker: { level: mob.level, criticalChancePercent: 5 + Math.max(0, bonusCriticalChancePercent) + incomingCrit, }, defender: { ...defenderStats, criticalAvoidancePercent: defenderStats.criticalAvoidancePercent, }, rules: { isAutoAttack: !attack.periodicTick && attack.animation === "attack" && (attack.castTimeMs ?? 0) === 0, canMiss: !attack.periodicTick && !nativeFallbackGatePassed && !sourceAlwaysHit && !sourceNoAttackMiss, canCrit: !attack.periodicTick && !applicationOnly, canDodge: !attack.periodicTick && !nativeFallbackGatePassed && attackKind === "melee" && !sourceAlwaysHit && !sourceNoActiveDefense && !sourceCannotDodge, canParry: !attack.periodicTick && attackKind === "melee" && !(deterrencePackage && defenderStunned) && !sourceAlwaysHit && !sourceNoActiveDefense && !sourceCannotParry, canBlock: !attack.periodicTick && !applicationOnly && attackKind !== "spell" && !sourceAlwaysHit && !sourceNoActiveDefense, ...(attack.targetMitigationSnapshotted ? { ignoreArmor: true, resistanceMode: "none" as const } : {}), ...(attack.periodicTick && attack.periodicIgnoresArmor ? { ignoreArmor: true } : {}), baseMissChancePercent: baseMissChancePercent(attackKind, mob.level, defender.level) + dualWieldWhiteMissPenalty - incomingHit, }, roll: stableCombatRoll(`${mob.id}:${attack.id}:${actorId}:${attackRevision}:${now}`), }); if (!deterrenceAbility || !result.landed || result.outcome === "deflect") return result; const deflection = resolveWow335DeterrenceIncoming({ ability: deterrenceAbility, auras: currentState.auras, defenderId: actorId, nowMs: now, incoming: { ...attack, attackKind, isAutoAttack: isEnemyWeaponSwing(attack) }, baseOutcome: result.outcome, defenderStunned, // The verified linked Aura288 makes the complete package facing-independent. attackerInFront: false, }); return deflection.outcome === "deflect" ? deflectLandedCombatResult(result) : result; } function deterrenceAbilityForActor(state: CombatState, actorId: string): AbilityDefinition | undefined { const owner = actorId === PLAYER_AURA_ENTITY_ID ? state : usePartyStore.getState().members.find((member) => member.id === actorId); if (!owner || owner.classId !== "hunter") return undefined; const ability = abilityById(WOW335_HUNTER_DETERRENCE_ABILITY_ID); return ability && isAbilityUnlocked(ability, owner.level) && wow335DeterrenceAbilityContract(ability) ? abilityAtLevel(ability, owner.level) : undefined; } function actorIsStunned(state: CombatState, actorId: string, now: number): boolean { const owner = actorId === PLAYER_AURA_ENTITY_ID ? state : usePartyStore.getState().members.find((member) => member.id === actorId); if (owner && owner.controlledUntil > now && owner.controlMechanic === "stun") return true; return state.auras.some((aura) => aura.targetId === actorId && (aura.expiresAt === null || aura.expiresAt > now) && (aura.definition.utilityTags?.includes("enemy-control-source:stun") || (aura.definition.id.startsWith("enemy-control:") && aura.definition.id.endsWith(":stun")))); } function deterrencePreventsIncomingSpellEffects( state: CombatState, actorId: string, attack: EnemyAttackDefinition, now: number, ): boolean { const ability = deterrenceAbilityForActor(state, actorId); if (!ability) return false; // This exact package guarantees deflection after any prior miss/resist gate. // Its hostile riders cannot land in either case; damage keeps its actual roll. return resolveWow335DeterrenceIncoming({ ability, auras: state.auras, defenderId: actorId, nowMs: now, baseOutcome: "hit", incoming: { ...attack, attackKind: enemyAttackKind(attack), isAutoAttack: isEnemyWeaponSwing(attack) }, defenderStunned: actorIsStunned(state, actorId, now), attackerInFront: false, }).outcome === "deflect"; } function completeDeterrenceLinkedAuras( auras: readonly ActiveAura[], ability: AbilityDefinition, sourceId: string, targetId: string, now: number, ): readonly ActiveAura[] { if (sourceId !== targetId || !wow335DeterrenceAbilityContract(ability) || !WOW335_DETERRENCE_PARENT_AURA_IDS.every((id) => auras.some((aura) => ( aura.definition.id === id && aura.sourceId === sourceId && aura.targetId === targetId && aura.appliedAt === now && aura.expiresAt === now + 5_000 )))) return auras; return WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS.reduce((current, effect) => { if (effect.kind !== "apply-aura") return current; return applyCombatAura(current, { definition: auraDefinitionForAbility(effect.aura, ability), sourceId, targetId, now }).auras; }, auras); } function synchronizedDeterrenceAuras(state: CombatState, auras: readonly ActiveAura[], now: number): readonly ActiveAura[] { const packageIds = new Set(WOW335_DETERRENCE_PACKAGE_AURA_IDS); let result = auras.filter((aura) => !packageIds.has(aura.definition.id) || aura.sourceId === aura.targetId); const candidates = result.filter((aura) => packageIds.has(aura.definition.id)); for (const candidate of candidates) { const ability = deterrenceAbilityForActor(state, candidate.targetId); if (!ability || !wow335DeterrenceActivePackage({ ability, auras: result, defenderId: candidate.targetId, nowMs: now })) { result = result.filter((aura) => !(packageIds.has(aura.definition.id) && aura.sourceId === candidate.sourceId && aura.targetId === candidate.targetId)); } } return result; } function talentAdjustedMaxResource( classId: ClassId, level: number, modifiers: readonly TalentModifier[], gearStats: ItemStats = EMPTY_ITEM_STATS, raceId: RaceId = "human", secondaryClassId?: ClassId | null, gender: GenderId = "male", statRuleset: CharacterStatRuleset = "wow", ): number { const profile = resourceProfileForClass(classId); const playerModifiers = modifiers.filter((modifier) => ( !/\b(?:pet|imp|voidwalker|succubus|felhunter|felguard)\b/i.test(modifier.scope) )); const primaryResourceModifiers = playerModifiers.filter((modifier) => { if (modifier.kind !== "max-resource-percent") return true; const scope = modifier.scope.toLowerCase(); const mentionsSpecificResource = /\b(?:mana|rage|energy|focus|runic power|felfury|deathfire)\b/.test(scope); if (!mentionsSpecificResource) return true; if (profile.type === "mana") return /\bmana\b/.test(scope); if (profile.type === "rage") return /\b(?:rage|felfury|deathfire)\b/.test(scope); if (profile.type === "energy") return /\b(?:energy|focus)\b/.test(scope); return /\brunic power\b/.test(scope); }); const maxResourcePercent = applyTalentModifierValue( 100, primaryResourceModifiers, "max-resource-percent", ) - 100; if (profile.type !== "mana") { return Math.max(0, Math.round(profile.maximum * (1 + maxResourcePercent / 100))); } const intellectModifiers = primaryResourceModifiers.filter((modifier) => ( modifier.kind !== "attribute-percent" || modifier.attribute === "intellect" )); const intellectPercent = applyTalentModifierValue(100, intellectModifiers, "attribute-percent") - 100; return Math.max(0, deriveCharacterStats(classId, level, gearStats, raceId, { intellect: intellectPercent, maxMana: maxResourcePercent, }, secondaryClassId, gender, statRuleset).mana); } /** Native MP is a real secondary pool whenever either RoM vocation uses it. */ function nativeManaPoolMaximum( classId: ClassId, level: number, modifiers: readonly TalentModifier[], gearStats: ItemStats, raceId: RaceId, secondaryClassId: ClassId | null | undefined, gender: GenderId, statRuleset: CharacterStatRuleset, auras: readonly ActiveAura[] = [], targetId = PLAYER_AURA_ENTITY_ID, ): number | null { if (statRuleset !== "rom" || !isRomClassId(classId)) return null; const manaModifiers = modifiers.filter((modifier) => { if (modifier.kind !== "max-resource-percent") return true; if (/\b(?:pet|imp|voidwalker|succubus|felhunter|felguard)\b/i.test(modifier.scope)) return false; const mentionsSpecificResource = /\b(?:mana|rage|energy|focus|runic power|felfury|deathfire)\b/i .test(modifier.scope); return !mentionsSpecificResource || /\bmana\b/i.test(modifier.scope); }); const maxManaPercent = applyTalentModifierValue( 100, manaModifiers, "max-resource-percent", ) - 100; const intellectModifiers = modifiers.filter((modifier) => ( modifier.kind !== "attribute-percent" || modifier.attribute === "intellect" )); const intellectPercent = applyTalentModifierValue( 100, intellectModifiers, "attribute-percent", ) - 100; const base = deriveCharacterStats( classId, level, gearStats, raceId, { intellect: intellectPercent, maxMana: maxManaPercent }, secondaryClassId, gender, statRuleset, ); if (!base.romNative) return null; return applyAuraStats(base, aurasForEntity(auras, targetId)).mana; } function resizeManaPool( pools: Readonly>>, maximums: Readonly>>, nextMaximum: number, preserveFraction: boolean, ): { readonly pools: Partial>; readonly maximums: Partial> } { const previousMaximum = maximums.mana ?? 0; const previousCurrent = pools.mana ?? (previousMaximum > 0 ? 0 : nextMaximum); const current = preserveFraction && previousMaximum > 0 ? Math.round(previousCurrent / previousMaximum * nextMaximum) : previousCurrent; return { maximums: { ...maximums, mana: Math.max(0, nextMaximum) }, pools: { ...pools, mana: clamp(current, 0, Math.max(0, nextMaximum)) }, }; } const AUTOMATIC_CLASS_PASSIVE_PREFIX = "class-passive:"; function automaticClassPassiveUnlockLevel(ability: AbilityDefinition): number { const statedLevel = ability.description.match(/\bLevel\s+(\d+)\s+Passive\b/i); return Math.max(ability.unlockLevel, statedLevel ? Number(statedLevel[1]) : 1); } function isAutomaticClassPassive(ability: AbilityDefinition): boolean { if (!ability.passive) return false; if (ability.source !== "coa-tree") return true; const nodeId = abilityTalentNodeId(ability); const node = nodeId ? talentNodeById(nodeId) : null; return Boolean( node?.isPassive && (node.abilityEssenceCost ?? 0) === 0 && (node.talentEssenceCost ?? 0) === 0, ); } function automaticClassPassiveModifiers( classId: ClassId, level: number, ): readonly TalentModifier[] { const modifiers: TalentModifier[] = []; const seenNames = new Set(); for (const definition of abilitiesForClass(classId)) { const ability = abilityAtLevel(definition, level); if ( !isAutomaticClassPassive(ability) || level < automaticClassPassiveUnlockLevel(ability) || seenNames.has(ability.name.toLowerCase()) ) { continue; } seenNames.add(ability.name.toLowerCase()); const rank = abilityRankForLevel(ability, level)?.rank ?? 1; const descriptionModifiers = parseTalentModifiers(ability.description).map((modifier) => ({ ...modifier, sourceTalentId: `${AUTOMATIC_CLASS_PASSIVE_PREFIX}${ability.id}`, sourceTalentName: ability.name, rank, description: ability.description, })); modifiers.push(...mergePassiveAuraResourceModifiers( ability, resourceProfileForClass(classId).type, rank, descriptionModifiers, )); } return modifiers; } function characterStatModifiers( classId: ClassId, level: number, ranks: TalentRanks, ): readonly TalentModifier[] { return [ ...automaticClassPassiveModifiers(classId, level), ...talentModifiersForRanks(ranks, classId), ]; } function initialResourceForMaximum(classId: ClassId, maximum: number): number { const profile = resourceProfileForClass(classId); return Math.round(maximum * (profile.maximum > 0 ? profile.initial / profile.maximum : 0)); } function defaultResourceMaximum(type: ResourceType): number { if (type === "nature-power") return 10; if (type === "psi") return 6; return 100; } function initialResourcePools( classId: ClassId, abilities: readonly AbilityDefinition[], activeMaximum: number, manaMaximum = 100, naturePowerMaximum = defaultResourceMaximum("nature-power"), includeManaPool = false, ): { pools: Partial>; maximums: Partial> } { const activeProfile = resourceProfileForClass(classId); const resourceTypes = new Set([activeProfile.type]); if (includeManaPool) resourceTypes.add("mana"); for (const ability of abilities) for (const cost of ability.costs ?? [ability.cost]) resourceTypes.add(cost.resource); const maximums: Partial> = {}; const pools: Partial> = {}; for (const type of resourceTypes) { if (type === "health") continue; const maximum = type === activeProfile.type ? activeMaximum : type === "mana" ? manaMaximum : type === "nature-power" ? naturePowerMaximum : defaultResourceMaximum(type); maximums[type] = maximum; pools[type] = type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi" ? 0 : maximum; } return { pools, maximums }; } export function resolvedAbilityCosts( ability: AbilityDefinition, modifiers: readonly TalentModifier[], state: Pick & Partial> & { readonly now?: number }, ): readonly { resource: ResourceType; amount: number }[] { const aggregated = new Map(); const activeResourceType = resourceProfileForClass(state.classId).type; for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) { const baseMana = cost.percentageBasis === "base-mana" ? ability.source === "runewaker" && state.statRuleset === "rom" ? deriveCharacterStats( state.classId, state.level ?? ability.unlockLevel, EMPTY_ITEM_STATS, state.raceId ?? "rom-human", {}, state.secondaryClassId, state.gender ?? "male", "rom", ).mana : baseResourcesFor(state.classId, state.level ?? ability.unlockLevel).mana : 0; const maximum = cost.percentageBasis === "base-mana" ? baseMana : cost.resource === "health" ? state.maxHealth : cost.resource === activeResourceType ? state.maxResource : state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource); const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount; const rawAmount = Math.max(0, Math.round(applyTalentModifierValue( baseAmount, modifiers, "resource-cost-percent", abilityTalentContext(ability), ))); const playerAuras = aurasForEntity(state.auras ?? [], PLAYER_AURA_ENTITY_ID); const mageClearcastingAura = state.classId === "mage" ? playerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG) && (state.now === undefined || aura.expiresAt === null || aura.expiresAt > state.now)) : undefined; const mageBrainFreezeAura = state.classId === "mage" && WOW335_MAGE_BRAIN_FREEZE_CONSUMER_SPELLS.has(ability.name) ? playerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_BRAIN_FREEZE_TAG) && (state.now === undefined || aura.expiresAt === null || aura.expiresAt > state.now)) : undefined; const mageMissileBarrageAura = state.classId === "mage" && wow335MageMissileBarrageConsumerEligible(ability.name) ? playerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_MISSILE_BARRAGE_TAG) && (state.now === undefined || aura.expiresAt === null || aura.expiresAt > state.now)) : undefined; // Arcane Blast's DBC child applies its +200% cost modifier only to the // next Arcane Blast, even though its aura is stored on the caster. const resourceCostAuras = ability.name === "Arcane Blast" ? playerAuras : playerAuras.filter((aura) => !aura.definition.utilityTags?.includes("wow335-arcane-blast-stack")); const druidClearcastingAuras = state.classId === "druid" && druidClearcastingEligible(ability, ability.target) ? resourceCostAuras : resourceCostAuras.filter((aura) => !aura.definition.utilityTags?.includes("wow335-druid-clearcasting")); const clearcastingAuras = state.classId === "shaman" && shamanClearcastingEligible(ability) ? druidClearcastingAuras : druidClearcastingAuras.filter((aura) => !aura.definition.utilityTags?.includes(SHAMAN_ELEMENTAL_FOCUS_AURA_TAG)); const resourceAdjusted = resolveAuraResourceValue( resolveAuraResourceValue(rawAmount, clearcastingAuras, cost.resource, "cost"), clearcastingAuras, "power", "cost", ); const hotStreakCostMultiplier = state.classId === "mage" && ability.name === "Pyroblast" && playerAuras.some((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG)) ? 0.5 : 1; const amount = Math.max(0, Math.round(resourceAdjusted * hotStreakCostMultiplier)); // Arcane Concentration's 100% reduction applies to the final cost even if // another modifier increased it; Brain Freeze and Missile Barrage only // remove Mana from their named consumer spells. No tooltip names Health. const finalAmount = cost.resource !== "health" && (mageClearcastingAura || ((mageBrainFreezeAura || mageMissileBarrageAura) && cost.resource === "mana")) ? 0 : amount; aggregated.set(cost.resource, (aggregated.get(cost.resource) ?? 0) + finalAmount); } return [...aggregated].map(([resource, amount]) => ({ resource, amount })); } function druidClearcastingEligible(ability: AbilityDefinition, effectiveTarget: string): boolean { if (ability.passive || ability.name === "Omen of Clarity") return false; const hasDamageOrHeal = ability.effects.some((effect) => [ "damage", "finisher-damage", "dot", "heal", "hot", "execute", "resource-drain", ].includes(effect.kind)); const hasOffensiveEffect = effectiveTarget === "hostile" && ability.effects.some((effect) => ( ["taunt", "status", "interrupt", "pull", "execute", "resource-drain", "threat"].includes(effect.kind) || (effect.kind === "apply-aura" && effect.aura.disposition === "debuff") )); return hasDamageOrHeal || hasOffensiveEffect; } function talentAdjustedResourceCost( ability: AbilityDefinition, modifiers: readonly TalentModifier[], ): number { return Math.max(0, Math.round(applyTalentModifierValue( ability.cost.amount, modifiers, "resource-cost-percent", abilityTalentContext(ability), ))); } function talentAdjustedCastTime( ability: AbilityDefinition, modifiers: readonly TalentModifier[], gearStats: ItemStats = EMPTY_ITEM_STATS, level = 1, classId: ClassId = "warrior", statRuleset: CharacterStatRuleset = "wow", ): number { if (ability.castTimeMs <= 0) return 0; const context = abilityTalentContext(ability); let duration = applyTalentModifierValue(ability.castTimeMs, modifiers, "cast-time-flat-ms", context); duration = applyTalentModifierValue(duration, modifiers, "cast-time-percent", context); const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", context); duration = hastedDuration( duration, normalizeCharacterStatRuleset(statRuleset, classId) === "rom" ? 0 : ratingPercent(gearStats.hasteRating, "spellHaste", level, classId), ); return Math.max(0, Math.round(duration / Math.max(0.1, hasteMultiplier * raceAffixHasteMultiplier()))); } function talentAdjustedCooldown(ability: AbilityDefinition, modifiers: readonly TalentModifier[]): number { if (ability.cooldownMs <= 0) return 0; const context = abilityTalentContext(ability); let duration = applyTalentModifierValue(ability.cooldownMs, modifiers, "cooldown-flat-ms", context); duration = applyTalentModifierValue(duration, modifiers, "cooldown-percent", context); return Math.max(0, Math.round(duration)); } function talentAdjustedGcd( ability: AbilityDefinition, modifiers: readonly TalentModifier[], gearStats: ItemStats = EMPTY_ITEM_STATS, level = 1, classId: ClassId = "warrior", statRuleset: CharacterStatRuleset = "wow", ): number { if (ability.gcdMs <= 0) return 0; const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", abilityTalentContext(ability)); const duration = hastedDuration( ability.gcdMs, normalizeCharacterStatRuleset(statRuleset, classId) === "rom" ? 0 : ratingPercent(gearStats.hasteRating, "spellHaste", level, classId), 1_000, ); return Math.max(1_000, Math.round(duration / Math.max(0.1, hasteMultiplier * raceAffixHasteMultiplier()))); } function talentAdjustedDuration( durationMs: number, ability: AbilityDefinition, modifiers: readonly TalentModifier[], ): number { return Math.max(0, Math.round(applyTalentModifierValue( durationMs, modifiers, "duration-percent", abilityTalentContext(ability), ))); } function talentAdjustedRange( range: number, ability: AbilityDefinition, modifiers: readonly TalentModifier[], ): number { return Math.max(0, applyTalentModifierValue(range, modifiers, "range-percent", abilityTalentContext(ability))); } /** Flat spell-range bonuses affect hostile cast distance only, never an AoE radius. */ function talentAdjustedCastRange( range: number, ability: AbilityDefinition, modifiers: readonly TalentModifier[], ): number { const context = abilityTalentContext(ability); const percentAdjusted = applyTalentModifierValue(range, modifiers, "range-percent", context); const castRangeAdjusted = ability.target === "hostile" ? applyTalentModifierValue(percentAdjusted, modifiers, "range-flat-yards", context) : percentAdjusted; return Math.max(0, castRangeAdjusted); } function talentAdjustedIncomingDamage( amount: number, modifiers: readonly TalentModifier[], school: DamageSchool = "physical", ): number { const tags = school === "physical" ? ["physical"] : ["magic", school]; return Math.max(0, Math.round( applyTalentModifierValue(amount, modifiers, "damage-taken-percent", { tags }), )); } function druidPredatoryInstinctsIncomingMultiplier( classId: ClassId, talentRanks: TalentRanks, auras: readonly ActiveAura[], actorId: string, attack: EnemyAttackDefinition, now: number, ): number { if (classId !== "druid") return 1; const rank = talentRanks[WOW335_DRUID_PREDATORY_INSTINCTS_TALENT_ID] ?? 0; if (rank <= 0) return 1; const form = wow335DruidShapeshiftFormForAuras(auras, actorId, now); return wow335DruidPredatoryInstinctsAreaDamageMultiplier(rank, form, attack.delivery === "area"); } function applyDruidFeralInstinctCooldownReductionInWork( work: MutableCastWork, ability: AbilityDefinition, damageDealt: number, now: number, ): void { if (work.classId !== "druid" || damageDealt <= 0 || !WOW335_DRUID_FERAL_INSTINCT_ATTACK_NAMES.has(ability.name)) return; const rank = Math.min(3, Math.max(0, Math.trunc(work.talentRanks["talent-799"] ?? 0))); if (rank <= 0) return; for (const name of ["Tiger's Fury", "Enrage"]) { const cooldownAbility = work.abilities.find((candidate) => candidate.name === name); if (!cooldownAbility) continue; const readyAt = work.cooldowns[cooldownAbility.id] ?? now; if (readyAt > now) work.cooldowns[cooldownAbility.id] = Math.max(now, readyAt - 1_000); } } /** Renderer-independent player defenses for hostile-player combat adapters. */ export function characterCombatDefender(state: CombatState): CombatDefenderStats { const stats = paladinRedoubtBlockStats( applyAuraStats(playerCharacterStats(state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, state.talentModifiers, state.gender, state.statRuleset), aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID)), state.classId, state.talentRanks, paladinShieldEquipped(state), ); const defender = combatDefenderFromCharacterStats(stats); const multiplier = talentAdjustedIncomingDamage(10_000, state.talentModifiers) / 10_000; return { ...defender, damageReductionPercent: (1 - (1 - (defender.damageReductionPercent ?? 0) / 100) * multiplier) * 100 }; } function recoveryAuras(state: CombatState, now: number): readonly ActiveAura[] { const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play"; const actors = [ ...(includePlayer ? [{ id: PLAYER_AURA_ENTITY_ID, health: state.health, role: state.gearRole, maxMana: state.maxResourcePools.mana ?? (resourceProfileForClass(state.classId).type === "mana" ? state.maxResource : 0) }] : []), ...usePartyStore.getState().members.map((member) => ({ id: member.id, health: member.health, role: member.role, maxMana: member.resourcePools.mana !== undefined ? companionResourceMaximum(member, "mana") : 0 })), ]; const inCombat = Object.values(state.mobs).some((mob) => !mob.dead && (mob.engaged || Object.values(mob.threatByActor).some((threat) => threat > 0))); return synchronizeHealerManaRecovery(state.auras, actors, state.settings.healerManaRecovery, inCombat, now); } /** Refresh the shared buff before companion recovery, even without a player tick. */ export function refreshHealerManaRecovery(now: number): void { useCombatStore.setState((state) => { const auras = recoveryAuras(state, now); return auras === state.auras ? state : { auras }; }); } function normalizedSettings( current: GameplaySettings, patch: Partial, ): GameplaySettings { return { ...normalizeGraphicsSettings(current, patch), showMobHealthBars: patch.showMobHealthBars ?? current.showMobHealthBars, showMobAggroRanges: patch.showMobAggroRanges ?? current.showMobAggroRanges, showTargetFrame: patch.showTargetFrame ?? current.showTargetFrame, showThreatMeter: patch.showThreatMeter ?? current.showThreatMeter, threatMeterPosition: normalizeHudPosition( patch.threatMeterPosition ?? current.threatMeterPosition, ), showFloatingCombatText: patch.showFloatingCombatText ?? current.showFloatingCombatText, showOwnDamageText: patch.showOwnDamageText ?? current.showOwnDamageText, showAllyDamageText: patch.showAllyDamageText ?? current.showAllyDamageText, showIncomingDamageText: patch.showIncomingDamageText ?? current.showIncomingDamageText, healerManaRecovery: patch.healerManaRecovery ?? current.healerManaRecovery, autoTargetNearest: patch.autoTargetNearest ?? current.autoTargetNearest, enableScreenShake: patch.enableScreenShake ?? current.enableScreenShake, masterVolume: clamp(Number.isFinite(patch.masterVolume) ? patch.masterVolume! : current.masterVolume, 0, 1), uiScale: clamp(Number.isFinite(patch.uiScale) ? patch.uiScale! : current.uiScale, 0.75, 1.25), minimapRotation: patch.minimapRotation === "north-up" || patch.minimapRotation === "player-up" ? patch.minimapRotation : current.minimapRotation, gameplayFrameRate: normalizeGameplayFrameRateLimit( patch.gameplayFrameRate, current.gameplayFrameRate, ), previewFrameRate: normalizePreviewFrameRateLimit( patch.previewFrameRate, current.previewFrameRate, ), }; } function gameplaySettingsEqual(left: GameplaySettings, right: GameplaySettings): boolean { return left.showMobHealthBars === right.showMobHealthBars && left.showMobAggroRanges === right.showMobAggroRanges && left.showTargetFrame === right.showTargetFrame && left.showThreatMeter === right.showThreatMeter && left.threatMeterPosition.x === right.threatMeterPosition.x && left.threatMeterPosition.y === right.threatMeterPosition.y && left.showFloatingCombatText === right.showFloatingCombatText && left.showOwnDamageText === right.showOwnDamageText && left.showAllyDamageText === right.showAllyDamageText && left.showIncomingDamageText === right.showIncomingDamageText && left.healerManaRecovery === right.healerManaRecovery && left.autoTargetNearest === right.autoTargetNearest && left.enableScreenShake === right.enableScreenShake && left.masterVolume === right.masterVolume && left.uiScale === right.uiScale && left.minimapRotation === right.minimapRotation && left.gameplayFrameRate === right.gameplayFrameRate && left.previewFrameRate === right.previewFrameRate && left.renderScale === right.renderScale && left.textureFiltering === right.textureFiltering && left.shadowQuality === right.shadowQuality && left.ambientFill === right.ambientFill; } function defaultMobStats( playerLevel: number, boss: boolean, serverEntry?: number, ): { maxHealth: number; xpReward: number; armor: number; maxMana: number; attackPower: number; attackPowerDamageContribution: number; attackDamage: number; swingMs: number; mechanicImmunities: readonly AzerothCoreMechanic[]; schoolImmunities: readonly AzerothCoreSchool[]; } { const server = serverEntry ? creatureRuntimeBaseStats(serverEntry, playerLevel) : null; const legacy = boss ? { maxHealth: 260 + playerLevel * 65, xpReward: 110 + playerLevel * 35 } : { maxHealth: 72 + playerLevel * 22, xpReward: 28 + playerLevel * 7 }; return { maxHealth: server?.health ?? legacy.maxHealth, xpReward: legacy.xpReward, armor: server?.armor ?? 0, maxMana: server?.mana ?? 0, attackPower: server?.attackPower ?? 0, attackPowerDamageContribution: server?.attackPowerDamageContribution ?? 0, attackDamage: server?.averageAttackDamage ?? (boss ? 12 + playerLevel * 2 : 4 + playerLevel * 2), swingMs: server?.baseAttackTimeMs ?? (boss ? 1_650 : 1_900), mechanicImmunities: server?.mechanicImmunities ?? [], schoolImmunities: server?.schoolImmunities ?? [], }; } export function stableCombatUnit(seed: string): number { let hash = 2166136261; for (let index = 0; index < seed.length; index += 1) { hash ^= seed.charCodeAt(index); hash = Math.imul(hash, 16777619); } return (hash >>> 0) / 0x1_0000_0000; } function paladinBlessedLifeDamageAfterIncomingHit( amount: number, classId: ClassId, talentRanks: TalentRanks, landed: boolean, targetActorId: string, mobId: string, attackId: string, attackRevision: number, now: number, ): number { const rank = classId === "paladin" ? talentRanks[WOW335_PALADIN_BLESSED_LIFE_TALENT_ID] ?? 0 : 0; if (!landed || rank <= 0) return amount; const roll = stableCombatUnit( `${targetActorId}:${mobId}:${attackId}:${attackRevision}:${now}:wow335-paladin-blessed-life`, ); return Math.round(amount * wow335PaladinBlessedLifeDamageMultiplierForRoll(rank, roll)); } const WOW335_PRIEST_PAIN_AND_SUFFERING_CASTS = new Set(["Mind Flay", "Mind Sear", "Mind Blast"]); /** Refresh a caster-owned Shadow Word: Pain after a generated Pain and Suffering proc. */ export function refreshPriestPainAndSufferingDot( effects: readonly TimedCombatEffect[], sourceActorId: string, targetId: string | null, triggerAbility: AbilityDefinition, talentRank: number, shadowWordPain: AbilityDefinition | null, now: number, ): readonly TimedCombatEffect[] { if (!targetId || !WOW335_PRIEST_PAIN_AND_SUFFERING_CASTS.has(triggerAbility.name) || !shadowWordPain) return effects; const dotDefinition = shadowWordPain.effects.find((effect) => effect.kind === "dot"); if (!dotDefinition || dotDefinition.kind !== "dot" || dotDefinition.ticks <= 0) return effects; const rank = Math.min(3, Math.max(0, Math.trunc(talentRank))); const talent = WOW335_TALENT_NODES.find((node) => node.id === "talent-1909"); const procChance = rank > 0 ? (talent?.rankEffects[rank - 1]?.procChance ?? 0) / 100 : 0; if (procChance <= 0 || stableCombatUnit( `${sourceActorId}:${triggerAbility.id}:${targetId}:${now}:wow335-priest-pain-and-suffering`, ) >= procChance) return effects; let refreshed = false; const nextEffects = effects.map((effect) => { if (effect.kind !== "dot" || effect.sourceActorId !== sourceActorId || effect.sourceAbilityId !== shadowWordPain.id || effect.targetId !== targetId || effect.remainingTicks <= 0 || effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs < now) return effect; refreshed = true; return { ...effect, remainingTicks: dotDefinition.ticks, nextTickAt: now + effect.intervalMs, }; }); return refreshed ? nextEffects : effects; } function applyWrathCastPushback( cast: ActiveCast | null, now: number, seed: string, pushbackReduction = 0, ): ActiveCast | null { if (!cast || cast.pushbackHits >= 2) return cast; const ability = abilityById(cast.abilityId); const resistance = ability ? abilityPushbackResistance(ability) : 0; if (resistance >= 1 || stableCombatUnit(`${seed}:pushback`) < resistance) return cast; const remainingPenalty = 1 - clamp(pushbackReduction, 0, 1); const pushbackHits = cast.pushbackHits + 1; if (cast.mode === "channel") { const lostTicks = Math.max(1, Math.ceil(cast.totalTicks * 0.25 * remainingPenalty)); const totalTicks = Math.max(cast.ticksCompleted, cast.totalTicks - lostTicks); return { ...cast, pushbackHits, totalTicks, completesAt: Math.max(now, cast.completesAt - cast.originalDurationMs * 0.25 * remainingPenalty), }; } return { ...cast, pushbackHits, completesAt: cast.completesAt + Math.round(500 * remainingPenalty) }; } function paladinConcentrationAuraPushbackReductionForTarget( auras: readonly ActiveAura[], targetId: string, now: number, playerClassId: ClassId, playerTalentRanks: TalentRanks, partyMembers: readonly { readonly id: string; readonly classId: ClassId; readonly talentRanks: TalentRanks }[], ): number { let strongestReduction = 0; for (const aura of auras) { if (aura.targetId !== targetId || aura.definition.id !== WOW335_PALADIN_CONCENTRATION_AURA_EFFECT_ID || aura.definition.exclusiveGroup !== "paladin:aura" || (aura.expiresAt !== null && aura.expiresAt <= now)) continue; const source = aura.sourceId === PLAYER_AURA_ENTITY_ID ? playerClassId === "paladin" ? { classId: playerClassId, talentRanks: playerTalentRanks } : null : partyMembers.find((member) => member.id === aura.sourceId && member.classId === "paladin"); if (!source || source.classId !== "paladin") continue; strongestReduction = Math.max( strongestReduction, wow335PaladinConcentrationAuraPushbackReduction( true, source.talentRanks[WOW335_PALADIN_IMPROVED_CONCENTRATION_AURA_TALENT_ID] ?? 0, ), ); } return strongestReduction; } export function deterministicEnemyCooldown( runtimeId: string, abilityId: string, activation: number, range: readonly [number, number], ): number { const minimum = Math.max(0, Math.round(range[0])); const maximum = Math.max(minimum, Math.round(range[1])); if (maximum === minimum) return minimum; return minimum + Math.floor( stableCombatUnit(`${runtimeId}:${abilityId}:${activation}`) * (maximum - minimum + 1), ); } function enemyAbilityConditionMet( mob: MobCombatState, mobs: Readonly>, attack: EnemyAttackDefinition, auras: readonly ActiveAura[] = [], now = combatNow(), ): boolean { const condition = attack.condition; if (!condition || condition.kind === "in-combat") return true; if (condition.kind === "self-health-percent") { const percent = mob.maxHealth > 0 ? mob.health / mob.maxHealth * 100 : 0; return percent >= condition.minimum && percent <= condition.maximum; } if (condition.kind === "encounter-state") { return dungeonEncounterStateMatches(condition.encounterId, condition.states); } if (condition.kind === "friendly-missing-aura") { return (!condition.onlyInCombat || mob.engaged) && selectedEnemyFriendlyCandidates(mob, mobs, attack, auras, now).length > 0; } const origin = getMobPosition(mob.id); if (!origin) return false; return Object.values(mobs).some((candidate) => { if (candidate.dead || candidate.health > candidate.maxHealth - condition.amount) return false; const position = getMobPosition(candidate.id); if (!position) return false; const dx = position[0] - origin[0]; const dy = position[1] - origin[1]; const dz = position[2] - origin[2]; return dx * dx + dy * dy + dz * dz <= condition.range * condition.range; }); } /** Existing enemy-team model; positions use point distance rather than core object-size range. */ function selectedEnemyFriendlyCandidates( mob: MobCombatState, mobs: Readonly>, attack: EnemyAttackDefinition, auras: readonly ActiveAura[], now: number, ): MobCombatState[] { const position = getMobPosition(mob.id); if (!position) return []; const missing = attack.condition?.kind === "friendly-missing-aura" ? attack.condition : undefined; return Object.values(mobs).filter((candidate) => { const targetPosition = getMobPosition(candidate.id); return !candidate.dead && !candidate.despawned && candidate.health > 0 && (!missing || candidate.engaged) && targetPosition && combatHasLineOfSight(position, targetPosition) && Math.hypot(position[0] - targetPosition[0], position[1] - targetPosition[1], position[2] - targetPosition[2]) <= Math.min(attack.range, missing?.range ?? attack.range) && (!missing || !aurasForEntity(auras, candidate.id, now) .some((aura) => aura.definition.id === `enemy-source-spell-${missing.spellId}`)); }).sort((left, right) => left.id.localeCompare(right.id)); } function selectedEnemyFriendlyRecipient( mob: MobCombatState, mobs: Readonly>, attack: EnemyAttackDefinition, auras: readonly ActiveAura[], now: number, ): string | undefined { const candidates = selectedEnemyFriendlyCandidates(mob, mobs, attack, auras, now); return candidates.length ? candidates[Math.min(candidates.length - 1, Math.floor(stableCombatUnit(`${mob.id}:${attack.id}:${mob.attackRevision}:${now}:friendly`) * candidates.length))]?.id : undefined; } function enemyAbilityNeedsHostileRange(attack: EnemyAttackDefinition): boolean { return attack.target === "primary" || attack.target === "random-party" || attack.target === "nearby-party"; } function initialEnemyAbilityReadyAt( mob: MobCombatState, now: number, ): Readonly> { return Object.fromEntries(mob.attacks.map((attack) => { const range = attack.initialCooldownMs ?? [450, 450] as const; return [ attack.id, now + deterministicEnemyCooldown(mob.id, attack.id, mob.attackRevision, range), ]; })); } function summonedMobCombatState( id: string, summoner: MobCombatState, creatureEntry: number, targetActorId: AggroActorId, position: CombatPosition, spawnGroupId?: string, ): MobCombatState { const stats = defaultMobStats(summoner.level, false, creatureEntry); const template = creatureTemplateReference(creatureEntry); const attack: EnemyAttackDefinition = normalizeEnemyAttacks([{ ...DEFAULT_ENEMY_ATTACK, cooldownMs: stats.swingMs, repeatCooldownMs: [stats.swingMs, stats.swingMs], initialCooldownMs: [450, 450], }])[0]; return { id, name: template?.name ?? `Summoned Creature ${creatureEntry}`, serverEntry: creatureEntry, creatureFamily: romCreatureFamilyForServerEntry(creatureEntry), summonedBy: summoner.id, ...(spawnGroupId ? { spawnGroupId } : {}), boss: false, lootSourceId: null, level: summoner.level, armor: stats.armor, maxMana: stats.maxMana, attackPower: stats.attackPower, mechanicImmunities: stats.mechanicImmunities, schoolImmunities: stats.schoolImmunities, resistances: { arcane: 0, fire: 0, frost: 0, nature: 0, shadow: 0 }, health: stats.maxHealth, maxHealth: stats.maxHealth, healthMultiplier: 1, damageMultiplier: 1, bonusLootChance: 0, dead: false, hasLoot: false, pendingLoot: [], looted: false, despawned: false, diedAt: null, corpseDespawnAt: null, xpReward: 0, experienceGranted: false, statuses: [], controlDiminishingReturns: {}, tauntDiminishingReturns: { applications: 0, resetAt: 0 }, auras: [], threatByActor: seedActorThreat({}, targetActorId), targetActorId, forcedTarget: null, lastDamageAt: 0, engaged: true, combatPhase: "chasing", homePosition: position, nextAttackAt: 0, dualWieldEnabled: false, nextEnemyOffhandAttackAt: 0, pendingExtraAttacks: [], lastDamagedActorId: null, abilityReadyAt: {}, abilityCastCounts: {}, activeCast: null, schoolLockedUntil: {}, attackRange: attack.range, attackDamage: stats.attackDamage, attackPowerDamageContribution: stats.attackPowerDamageContribution, swingMs: stats.swingMs, aggroRange: 0, leashRange: summoner.leashRange, moveSpeed: summoner.moveSpeed, attackRevision: 0, attackAnimation: "attack", attacks: [attack], nextAttackIndex: 0, lastAttackId: null, woundRevision: 0, nextWoundAnimationAt: 0, deathRevision: 0, }; } function currentOrigin(state: CombatState, origin?: CombatPosition): CombatPosition { return origin ?? state.playerPosition; } function aurasForEntity(auras: readonly ActiveAura[], entityId: string, now?: number): ActiveAura[] { return effectiveActorAuras(auras, entityId, now); } /** Effective item-dependent passives reactivate from authoritative aura state at expiry. */ export function effectiveActorAuras( auras: readonly ActiveAura[], entityId: string, now?: number, weaponChannel?: EnemyDisarmWeaponChannel, ): ActiveAura[] { let active = wow335PaladinAuraMasteryAurasForEntity(auras, entityId, now); if (activeEnemyDisarmChannels(auras, entityId, now ?? 0).size > 0 || weaponChannel) { const state = useCombatStore.getState(); const member = entityId === PLAYER_AURA_ENTITY_ID ? undefined : usePartyStore.getState().members.find((candidate) => candidate.id === entityId); const owner = member ?? (entityId === PLAYER_AURA_ENTITY_ID ? state : null); if (owner) { const abilities = member ? abilitiesForCharacter(member.classId, member.secondaryClassId) : state.abilities; const requirements = abilities.flatMap((ability) => { const rank = abilityRankForLevel(ability, owner.level); const equipped = rank?.wowWeaponRequirements ?? ability.wowWeaponRequirements; return equipped ? (rank?.effects ?? ability.effects).flatMap((effect) => effect.kind === "apply-aura" ? [{ ...equipped, auraDefinitionId: effect.aura.id, passive: ability.passive === true }] : []) : []; }); const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: member?.id ?? PLAYER_EQUIPMENT_OWNER_ID, ownerKind: member ? "party" : "player", classId: owner.classId, secondaryClassId: owner.secondaryClassId, specializationId: owner.specializationId, role: member?.role ?? state.gearRole, level: owner.level, }); const form = owner.classId === "druid" ? wow335DruidShapeshiftFormForAuras(auras, entityId, now ?? 0) : undefined; const feralForm = form === "cat" || form === "bear"; if (weaponChannel) { const allowedIds = new Set(enemyDisarmAurasForWeaponChannel( auras, entityId, now ?? 0, weaponChannel, requirements, loadout, feralForm, ).map((aura) => aura.instanceId)); active = active.filter((aura) => allowedIds.has(aura.instanceId)); } else { const suppressed = enemyDisarmItemDependentAuraSuppressionIds( auras, entityId, now ?? 0, requirements, loadout, feralForm, ); active = active.filter((aura) => !suppressed.has(aura.instanceId)); } } } if (!active.some((aura) => aura.definition.exclusiveGroup === "coa-guardian-formation" || aura.definition.id.startsWith("coa:803432:paragon-"))) return active; const combat = useCombatStore.getState(); const member = entityId === PLAYER_AURA_ENTITY_ID ? undefined : usePartyStore.getState().members.find((candidate) => candidate.id === entityId); const owner = member ?? (entityId === PLAYER_AURA_ENTITY_ID ? combat : null); if (!owner || owner.classId !== "guardian") return active; const shield = resolvedEquipmentLoadout(combat.equipment, combat.inventory, { ownerId: member?.id ?? PLAYER_EQUIPMENT_OWNER_ID, ownerKind: member ? "party" : "player", classId: owner.classId, secondaryClassId: owner.secondaryClassId, specializationId: owner.specializationId, role: member?.role ?? combat.gearRole, level: owner.level, })["off-hand"]; const currentAuras = active.filter((aura) => aura.expiresAt === null || aura.expiresAt > (now ?? combatNow())); return [...projectCoaGuardianFormationAuras(currentAuras, { id: entityId, classId: owner.classId, talentRanks: owner.talentRanks, health: owner.health, }, now ?? combatNow(), shield?.armorType === "shield" ? shield.stats.armor : 0)]; } function applyRomWardenRevivalOnHit(auras: readonly ActiveAura[], entityId: string, now: number): ActiveAura[] { const charge = applyCombatAura([...auras], { definition: ROM_WARDEN_REVIVAL_CHARGE_AURA, sourceId: entityId, targetId: entityId, now, }); return [...applyCombatAura(charge.auras, { definition: ROM_WARDEN_REVIVAL_DODGE_AURA, sourceId: entityId, targetId: entityId, now, }).auras]; } function hasControlImmunity( auras: readonly ActiveAura[], entityId: string, mechanic: EnemyControlMechanic, ): boolean { return aurasForEntity(auras, entityId).some((aura) => ( aura.definition.controlImmunities?.includes(mechanic) )); } const MAGIC_DAMAGE_SCHOOLS: readonly DamageSchool[] = ["holy", "fire", "nature", "frost", "shadow", "arcane"]; function deathKnightAntiMagicShellBlocksMagicEffect( auras: readonly ActiveAura[], targetId: string, school: DamageSchool, ): boolean { return school !== "physical" && auras.some((aura) => ( aura.targetId === targetId && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-shell") )); } function deathKnightAntiMagicShellRunicPowerGain( auras: readonly ActiveAura[], consumptions: readonly AbsorbConsumption[], targetId: string, school: DamageSchool, now: number, ): number { if (school === "physical") return 0; const liveShellIds = new Set(auras.filter((aura) => ( aura.targetId === targetId && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-shell") )).map((aura) => aura.instanceId)); const absorbed = consumptions.reduce((total, consumption) => ( liveShellIds.has(consumption.auraInstanceId) ? total + Math.max(0, consumption.amount) : total ), 0); // Spell 49088 receives 20% of absorbed damage in the DBC's 10x RP units. return absorbed * 0.02; } function grantDeathKnightAntiMagicShellRunicPower( classId: ClassId, resource: number, resourcePools: Partial>, maxResource: number, maxResourcePools: Partial>, gain: number, ): { resource: number; resourcePools: Partial> } { if (classId !== "death-knight" || gain <= 0) return { resource, resourcePools }; const current = resourcePools["runic-power"] ?? resource; const maximum = maxResourcePools["runic-power"] ?? maxResource; const runicPower = clamp(current + gain, 0, maximum); return { resource: runicPower, resourcePools: { ...resourcePools, "runic-power": runicPower }, }; } function applyDeathKnightAntiMagicZoneInWork( work: MutableCastWork, center: CombatPosition, radius: number, durationMs: number, capacityBase: number, capacityPerAttackPower: number, absorbFraction: number, now: number, playerPosition: CombatPosition, ): void { const sourceStats = playerCharacterStats( work.classId, work.progression.level, work.gearStats, work.raceId, work.secondaryClassId, work.talentModifiers, work.gender, work.statRuleset, ); const capacity = Math.max(0, Math.round(capacityBase + sourceStats.attackPower * capacityPerAttackPower)); const candidates = [ { id: PLAYER_AURA_ENTITY_ID, health: work.health, position: playerPosition }, ...usePartyStore.getState().members.map((member) => { const actor = ensureCompanionActorInWork(work, member.id); return { id: member.id, health: actor?.health ?? member.health, position: getPartyRuntimePosition(member.id) }; }), ]; const inRange = candidates.filter((candidate) => candidate.health > 0 && candidate.position && Math.sqrt(distanceSquared(center, candidate.position)) <= radius && combatHasLineOfSight(center, candidate.position)); const inRangeIds = new Set(inRange.map((candidate) => candidate.id)); work.auras = work.auras.filter((aura) => !( aura.sourceId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone") && !inRangeIds.has(aura.targetId) )); for (const candidate of inRange) { if (work.auras.some((aura) => aura.targetId === candidate.id && aura.sourceId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone"))) continue; work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:death-knight:anti-magic-zone-absorb", name: "Anti-Magic Zone", description: "Absorbs 75% of incoming magic damage until its capacity is spent.", disposition: "buff", durationMs, maxStacks: 1, stackBehavior: "replace", utilityTags: ["wow335-death-knight-anti-magic-zone"], absorbs: [{ id: "wow335:death-knight:anti-magic-zone-pool", amount: capacity, schools: MAGIC_DAMAGE_SCHOOLS, fraction: absorbFraction, }], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: candidate.id, now, }).auras]; } } function auraDefinitionForAbility( definition: AuraDefinition, ability: AbilityDefinition, durationMs = definition.durationMs, ): AuraDefinition { const defaultPartyBuffFamily = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()); // Focus Magic's ally aura reacts to that ally's spell criticals, then grants // the Mage its separate 10-second critical-strike buff. The current talent // row supplies +1% to the ally; the proc remains +3% for 10 seconds. const focusMagicProc = ability.name === "Focus Magic" ? [{ id: "wow335-mage-focus-magic-proc", triggers: "crit" as const, attackKinds: ["spell"] as const, chance: 1, action: { kind: "custom" as const, id: "wow335-mage-focus-magic-proc" }, }] : []; return { ...definition, description: definition.description ?? ability.description, icon: definition.icon ?? ability.icon, durationMs, ...(focusMagicProc.length ? { procs: [...(definition.procs ?? []), ...focusMagicProc] } : {}), ...(defaultPartyBuffFamily && !definition.exclusiveGroup ? { uniqueBySource: false, exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`, } : {}), }; } function improvedEarthShieldAuraDefinition( definition: AuraDefinition, ability: AbilityDefinition, talentRank: number, ): AuraDefinition { if (ability.classId !== "shaman" || ability.name !== "Earth Shield") return definition; // Earth Shield's generated DBC spell has two aura effects. Aura 4 owns the // heal proc, while Aura 149 is the final aura in the shared exclusive group. // Merge the proc onto the final aura so the second effect does not replace // the mechanics attached to the first one. const earthShieldProcs = ability.effects .filter((effect): effect is Extract => effect.kind === "apply-aura") .flatMap((effect) => effect.aura.procs ?? []) .filter((proc) => proc.id.endsWith(":earth-shield")); const procsById = new Map((definition.procs ?? []).map((proc) => [proc.id, proc] as const)); for (const proc of earthShieldProcs) procsById.set(proc.id, proc); return { ...definition, procs: [...procsById.values()].map((proc) => proc.id.endsWith(":earth-shield") ? { ...proc, charges: wow335ShamanImprovedEarthShieldProcCharges(proc.charges ?? 0, talentRank) } : proc), }; } function nativeAuraPercentFactor(auras: readonly ActiveAura[], statName: string): number { const percent = auras.reduce((sum, aura) => sum + (aura.definition.modifiers ?? []).reduce((subtotal, modifier) => ( subtotal + (modifier.kind === "stat" && modifier.stat === statName && modifier.operation === "percent" ? modifier.value * (modifier.perStack === false ? 1 : aura.stacks) : 0) ), 0), 0); return Math.max(0, 1 + percent); } function nativeAuraFlatValue(auras: readonly ActiveAura[], statName: string): number { return auras.reduce((sum, aura) => sum + (aura.definition.modifiers ?? []).reduce((subtotal, modifier) => ( subtotal + (modifier.kind === "stat" && modifier.stat === statName && modifier.operation === "flat" ? modifier.value * (modifier.perStack === false ? 1 : aura.stacks) : 0) ), 0), 0); } function nativeAuraModifiers(native: RomNativeDerivedStats, auras: readonly ActiveAura[]): RomNativeStatModifiers { const attributes: Partial> = {}; const channels: Partial> = {}; const rates: Partial> = {}; const attributesByAura: Readonly> = { strength: ["strength"], stamina: ["stamina"], intellect: ["intellect"], wisdom: ["wisdom"], agility: ["agility", "dexterity"], }; const primaryAliases = ["all-primary", "all-primary-attributes"] as const; const attributeAliases: Readonly> = { strength: [...attributesByAura.strength, ...primaryAliases], stamina: [...attributesByAura.stamina, ...primaryAliases], intellect: [...attributesByAura.intellect, ...primaryAliases], wisdom: [...attributesByAura.wisdom, ...primaryAliases], agility: [...attributesByAura.agility, ...primaryAliases], }; const globalAttributeRate = primaryAliases.reduce( (factor, alias) => factor * nativeAuraPercentFactor(auras, alias), 1, ); if (globalAttributeRate !== 1) rates.allAttributes = globalAttributeRate; for (const key of Object.keys(attributeAliases) as RomNativeAttributeKey[]) { const aliases = attributeAliases[key]; const flat = aliases.reduce((sum, alias) => sum + nativeAuraFlatValue(auras, alias), 0); if (flat) attributes[key] = flat; const ownAliases = attributesByAura[key]; const rate = ownAliases.reduce((factor, alias) => factor * nativeAuraPercentFactor(auras, alias), 1); if (rate !== 1) rates[key] = rate; } const channelBindings: readonly { readonly aliases: readonly string[]; readonly channel: RomNativeAdditiveChannel; readonly rate?: RomNativeRateChannel; readonly value: number; }[] = [ { aliases: ["maximum-health"], channel: "health", rate: "health", value: native.health.max }, { aliases: ["maximum-mana"], channel: "mana", rate: "mana", value: native.mana.max }, { aliases: ["physical-attack", "attack-power"], channel: "physicalAttack", rate: "physicalAttack", value: native.physicalAttack }, { aliases: ["physical-defense", "armor"], channel: "physicalDefense", rate: "physicalDefense", value: native.physicalDefense }, { aliases: ["magical-defense"], channel: "magicalDefense", rate: "magicalDefense", value: native.magicalDefense }, { aliases: ["magical-attack"], channel: "magicalAttack", rate: "magicalAttack", value: native.magicalAttack }, // WotLK spell-power is a simulator pass-through of RoM MDMG only. { aliases: ["spell-power"], channel: "magicalDamage", rate: "magicalDamage", value: native.magicalDamage }, { aliases: ["physical-hit"], channel: "physicalHit", value: native.physicalHit }, { aliases: ["magical-hit"], channel: "magicalHit", value: native.magicalHit }, { aliases: ["physical-dodge", "dodge"], channel: "physicalDodge", value: native.physicalDodge }, { aliases: ["magical-dodge"], channel: "magicalDodge", value: native.magicalDodge }, { aliases: ["parry"], channel: "parry", value: native.parry }, { aliases: ["physical-critical-strike"], channel: "physicalCritical", value: native.physicalCritical }, { aliases: ["magical-critical-strike"], channel: "magicalCritical", value: native.magicalCritical }, { aliases: ["magical-damage-point"], channel: "magicalDamagePoint", value: native.magicalDamagePoint }, { aliases: ["heal-point"], channel: "healPoint", value: native.healing.healPoint }, { aliases: ["heal-absorb-rate"], channel: "healAbsorbRate", value: native.healing.targetHealAbsorbRate }, { aliases: ["heal-absorb"], channel: "healAbsorb", value: native.healing.targetHealAbsorb }, ]; for (const binding of channelBindings) { const { aliases, channel, rate, value } = binding; if (rate) { const directFlat = aliases.reduce((sum, alias) => sum + nativeAuraFlatValue(auras, alias), 0); if (directFlat) channels[channel] = (channels[channel] ?? 0) + directFlat; const factor = aliases.reduce((current, alias) => current * nativeAuraPercentFactor(auras, alias), 1); if (factor !== 1) rates[rate] = (rates[rate] ?? 1) * factor; } else { // Point channels have no verified BodyRate. Apply their aura in their own native point units. const delta = aliases.reduce((sum, alias) => ( sum + resolveAuraStatValue(value, auras, alias) - value ), 0); if (delta) channels[channel] = (channels[channel] ?? 0) + delta; } } // Source aura modifier types 10/11 bind directly to ReHP/ReMP. Keep the // translation scoped to the native aura IDs and exact source modifier shapes; // shared/WoW regeneration auras use different time units and are not Body rates. for (const aura of auras) { const sourceId = aura.definition.id; if (!sourceId.startsWith("rom-aura-")) continue; for (const modifier of aura.definition.modifiers ?? []) { const amount = modifier.value * (modifier.perStack === false ? 1 : aura.stacks); if (sourceId.endsWith("-health-regeneration") && modifier.kind === "stat" && modifier.stat === "health-regeneration" && modifier.operation === "flat") { channels.healthRecovery = (channels.healthRecovery ?? 0) + amount; } else if (sourceId.endsWith("-mana-regeneration") && modifier.kind === "resource" && modifier.resource === "mana" && modifier.property === "regeneration" && modifier.operation === "flat") { channels.manaRecovery = (channels.manaRecovery ?? 0) + amount; } } } for (const aura of auras) for (const modifier of aura.definition.modifiers ?? []) { if (modifier.kind !== "resource" || modifier.resource !== "mana" || modifier.property !== "maximum") continue; const amount = modifier.value * (modifier.perStack === false ? 1 : aura.stacks); if (modifier.operation === "flat") channels.mana = (channels.mana ?? 0) + amount; else rates.mana = (rates.mana ?? 1) * Math.max(0, 1 + amount); } return { attributes, channels, rates }; } function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): CharacterStats { if (base.romNative) { const blessingAuras = auras.filter((aura) => ( aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG) )); const ordinaryAuras = blessingAuras.length ? auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG)) : auras; let native = applyRomNativeStatModifiers(base.romNative, nativeAuraModifiers(base.romNative, ordinaryAuras)); if (blessingAuras.length) { // Holy Blessing is a post-rate `(current Wisdom + 115) × 1.105` rule. // Translate its final Wisdom delta back through existing BodyRate factors // so gear/aura Wisdom rates are not applied to the +115 source flat again. const targetWisdom = romPriestHolyBlessingWisdom(native.attributes.wisdom); const wisdomRate = Math.max(0.000001, native.rateFactors.wisdom * native.rateFactors.allAttributes); native = applyRomNativeStatModifiers(native, { attributes: { wisdom: (targetWisdom - native.attributes.wisdom) / wisdomRate }, }); } const armor = native.physicalDefense; return { ...base, strength: native.attributes.strength, agility: native.attributes.agility, stamina: native.attributes.stamina, intellect: native.attributes.intellect, health: native.health.max, mana: native.usesMana ? native.mana.max : 0, attackPower: native.physicalAttack, rangedAttackPower: native.physicalAttack, spellPower: native.magicalDamage, armor, armorReductionPercent: armorReductionPercent(armor, base.level), romNative: native, combatStatModel: "rom-native-normalized", }; } const holyBlessing = romPriestHolyBlessingStats(base, auras); const statAuras = holyBlessing.active ? auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG)) : auras; const stat = (value: number, ...names: string[]) => names.reduce( (current, name) => resolveAuraStatValue(current, statAuras, name), value, ); const primary = (value: number, name: string, alias?: string) => stat( value, name, ...(alias ? [alias] : []), "all-primary", "all-primary-attributes", ); const resistance = (value: number, school: string) => stat( value, `resistance:${school}`, "resistance:all", "all-resistance", "magical-defense", ); const stamina = primary(base.stamina, "stamina"); const spirit = holyBlessing.wisdom; const intellect = primary(base.intellect, "intellect"); const spiritCritRating = spirit * resolveAuraStatValue(0, statAuras, "spirit-to-spell-crit-rating"); const staminaHealth = healthFromTotalStamina(stamina) - healthFromTotalStamina(base.stamina); return { ...base, strength: primary(base.strength, "strength"), agility: primary(base.agility, "agility", "dexterity"), stamina, intellect, spirit, health: stat(base.health + staminaHealth, "maximum-health"), mana: stat(base.mana, "maximum-mana"), attackPower: stat(base.attackPower, "attack-power", "physical-attack"), rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"), spellPower: stat(base.spellPower + holyBlessing.spellPowerBonus, "spell-power", "magical-attack"), armor: stat(base.armor, "armor", "physical-defense"), blockValue: stat(base.blockValue, "block-value"), meleeCritPercent: stat(base.meleeCritPercent, "critical-strike", "physical-critical-strike"), rangedCritPercent: stat(base.rangedCritPercent, "critical-strike", "physical-critical-strike"), spellCritPercent: stat( base.spellCritPercent + ratingPercent(spiritCritRating, "spellCrit", base.level, "mage"), "spell-critical-strike", "magical-critical-strike", ), meleeHitPercent: stat(base.meleeHitPercent, "hit", "physical-hit"), rangedHitPercent: stat(base.rangedHitPercent, "hit", "physical-hit", "ranged-hit"), spellHitPercent: stat(base.spellHitPercent, "spell-hit", "magical-hit"), meleeHastePercent: stat(base.meleeHastePercent, "haste", "melee-haste", "attack-speed"), rangedHastePercent: stat(base.rangedHastePercent, "haste", "ranged-haste", "attack-speed"), spellHastePercent: stat(base.spellHastePercent, "haste", "spell-haste"), dodgePercent: stat(base.dodgePercent, "dodge"), parryPercent: stat(base.parryPercent, "parry"), blockPercent: stat(base.blockPercent, "block", "block-chance"), expertise: stat(base.expertise, "expertise"), armorPenetrationPercent: stat(base.armorPenetrationPercent, "armor-penetration"), arcaneResistance: resistance(base.arcaneResistance, "arcane"), fireResistance: resistance(base.fireResistance, "fire"), frostResistance: resistance(base.frostResistance, "frost"), natureResistance: resistance(base.natureResistance, "nature"), shadowResistance: resistance(base.shadowResistance, "shadow"), }; } /** Resolve the stat-changing, active auras for one actor over their derived stats. */ export function auraAdjustedCharacterStats( base: CharacterStats, auras: readonly ActiveAura[], actorId: string, now = Date.now(), ): CharacterStats { return applyAuraStats(base, aurasForEntity(auras, actorId, now)); } function nativeHealingTargetStats( actorId: string, player: Pick, playerLevel: number, auras: readonly ActiveAura[], now: number, ): RomNativeDerivedStats | undefined { const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId); if (!member && actorId !== PLAYER_AURA_ENTITY_ID) return undefined; const owner = member ?? player; if (owner.statRuleset !== "rom") return undefined; return auraAdjustedCharacterStats(deriveCharacterStats( owner.classId, member?.level ?? playerLevel, owner.gearStats, owner.raceId, {}, owner.secondaryClassId, owner.gender, "rom", ), auras, actorId, now).romNative; } function hunterPetIncomingDefender( state: CombatState, summon: CombatSummon, now: number, ): CombatDefenderStats | null { const profile = summon.hunterPet; if (!profile) return null; const ownerAuras = aurasForEntity( state.auras, summon.ownerActorId === PLAYER_AGGRO_ID ? PLAYER_AURA_ENTITY_ID : summon.ownerActorId, now, ); let ownerStats: CharacterStats | null = null; if (summon.ownerActorId === PLAYER_AGGRO_ID && state.classId === "hunter") { ownerStats = applyAuraStats(playerCharacterStats( state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, state.talentModifiers, state.gender, state.statRuleset, ), ownerAuras); } else { const ownerMember = usePartyStore.getState().members.find((member) => ( member.id === summon.ownerActorId && member.classId === "hunter" )); if (ownerMember) { ownerStats = applyAuraStats(playerCharacterStats( ownerMember.classId, ownerMember.level, ownerMember.gearStats, ownerMember.raceId, ownerMember.secondaryClassId ?? null, talentModifiersForRanks(ownerMember.talentRanks, ownerMember.classId), ownerMember.gender, ownerMember.statRuleset ?? "wow", ), ownerAuras); } } const ownerResistances: Wow335HunterPetOwnerResistances = { // The source iterates all non-physical schools, including Holy. This game // model has no native Holy resistance field, so keep that value at zero. holy: 0, arcane: ownerStats?.arcaneResistance ?? 0, fire: ownerStats?.fireResistance ?? 0, nature: ownerStats?.natureResistance ?? 0, frost: ownerStats?.frostResistance ?? 0, shadow: ownerStats?.shadowResistance ?? 0, }; const inherited = wow335HunterPetInheritedDefenses(ownerStats?.armor ?? 0, ownerResistances); const baseline = wow335HunterPetBaseDefenseForLevel(profile.baseStats.level); const petAuras = aurasForEntity(state.auras, summon.id, now); const resolveAliases = (value: number, aliases: readonly string[]): number => aliases.reduce( (current, alias) => resolveAuraStatValue(current, petAuras, alias), value, ); const petAgility = resolveAliases(baseline.agility, [ "agility", "dexterity", "all-primary", "all-primary-attributes", ]); const composedArmor = wow335HunterPetArmorForProfile({ petBaseArmor: baseline.armor, petAgility, ownerArmor: ownerStats?.armor ?? 0, }); const armor = Math.max(0, resolveAliases(composedArmor, ["armor", "physical-defense"])); const resistance = (school: keyof Wow335HunterPetOwnerResistances): number => Math.max(0, resolveAliases(inherited.resistances[school], [ `resistance:${school}`, "resistance:all", "all-resistance", "magical-defense", ]), ); return { level: profile.baseStats.level, armor, resistances: { holy: 0, arcane: resistance("arcane"), fire: resistance("fire"), nature: resistance("nature"), frost: resistance("frost"), shadow: resistance("shadow"), }, }; } function paladinAuraSourceSpellPower( sourceActorId: string, player: Pick, auras: readonly ActiveAura[], now: number, ): number { if (sourceActorId === PLAYER_AURA_ENTITY_ID) { const base = playerCharacterStats( player.classId, player.level, player.gearStats, player.raceId, player.secondaryClassId, player.talentModifiers, player.gender, player.statRuleset, ); return applyAuraStats(base, aurasForEntity(auras, sourceActorId, now)).spellPower; } const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); if (!member) return 0; const base = playerCharacterStats( member.classId, member.level, member.gearStats, member.raceId, member.secondaryClassId ?? null, talentModifiersForRanks(member.talentRanks, member.classId), member.gender, member.statRuleset ?? "wow", ); return applyAuraStats(base, aurasForEntity(auras, sourceActorId, now)).spellPower; } function consumeInnerFireCharges( auras: readonly ActiveAura[], targetId: string, landed: boolean, attackKind: WotlkAttackKind, ): ActiveAura[] { if (!landed || (attackKind !== "melee" && attackKind !== "ranged")) return [...auras]; return auras.flatMap((aura) => { if (aura.targetId !== targetId || !aura.definition.name.startsWith("Inner Fire (DBC ")) return [aura]; return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; }); } function auraMaximumHealthBonus( base: CharacterStats, auras: readonly ActiveAura[], targetId: string, ): number { return Math.round(applyAuraStats(base, aurasForEntity(auras, targetId)).health - base.health); } function auraAdjustedPlayerMaxHealth( classId: ClassId, level: number, modifiers: readonly TalentModifier[], gearStats: ItemStats, raceId: RaceId, secondaryClassId: ClassId | null | undefined, auras: readonly ActiveAura[], gender: GenderId = "male", statRuleset: CharacterStatRuleset = "wow", ): number { const baseStats = playerCharacterStats( classId, level, gearStats, raceId, secondaryClassId ?? null, modifiers, gender, statRuleset, ); if (baseStats.romNative) { const talentAdjustedHealth = talentAdjustedMaxHealth( classId, level, modifiers, gearStats, raceId, secondaryClassId, gender, statRuleset, ); const healthFactor = talentAdjustedHealth / Math.max(1, baseStats.health); const native = healthFactor === 1 ? baseStats.romNative : applyRomNativeStatModifiers(baseStats.romNative, { rates: { health: healthFactor } }); return Math.max(1, applyAuraStats({ ...baseStats, health: native.health.max, romNative: native, }, aurasForEntity(auras, PLAYER_AURA_ENTITY_ID)).health); } return Math.max( 1, talentAdjustedMaxHealth(classId, level, modifiers, gearStats, raceId, secondaryClassId, gender, statRuleset) + auraMaximumHealthBonus(baseStats, auras, PLAYER_AURA_ENTITY_ID), ); } const DEFAULT_PARTY_BUFF_FAMILY = Object.freeze(new Map([ ["power word: fortitude", "fortitude"], ["prayer of fortitude", "fortitude"], ["divine spirit", "spirit"], ["prayer of spirit", "spirit"], ["shadow protection", "shadow-protection"], ["prayer of shadow protection", "shadow-protection"], ["arcane intellect", "arcane-intellect"], ["arcane brilliance", "arcane-intellect"], ["mark of the wild", "mark-of-the-wild"], ["gift of the wild", "mark-of-the-wild"], ])); const WOW335_HUNTER_GO_FOR_THE_THROAT_ICD_TAG = "wow335-hunter-go-for-the-throat-icd"; function defaultPartyBuffPotency( effects: readonly Extract[], ): number { return effects.reduce((total, effect) => total + (effect.aura.modifiers ?? []).reduce( (subtotal, modifier) => subtotal + Math.abs(modifier.value) * (modifier.operation === "percent" ? 1_000 : 1), 0, ), 0); } function activeHunterPetId(summons: readonly CombatSummon[], now: number): string | null { return summons.find((summon) => ( summon.kind === "pet" && summon.ownerActorId === PLAYER_AGGRO_ID && summon.health > 0 && summon.expiresAt > now ))?.id ?? null; } function activeWardenNatureCrystal(summons: readonly CombatSummon[], now: number): boolean { return summons.some((summon) => summon.creatureId === 102325 && summon.ownerActorId === PLAYER_AGGRO_ID && summon.health > 0 && summon.expiresAt > now); } function activeWardenDamageTransferPet( summons: readonly CombatSummon[], ownerActorId: AggroActorId, now: number, ownerPosition: CombatPosition | null, ): CombatSummon | null { if (!ownerPosition) return null; return summons.find((summon) => [102297, 102324, 102325].includes(summon.creatureId) && summon.ownerActorId === ownerActorId && summon.health > 0 && summon.expiresAt > now && Math.hypot( summon.position[0] - ownerPosition[0], summon.position[1] - ownerPosition[1], summon.position[2] - ownerPosition[2], ) <= 300) ?? null; } function resolveWarlockSoulLinkDamage( amount: number, classId: ClassId, talentRanks: TalentRanks, auras: readonly ActiveAura[], summons: readonly CombatSummon[], ownerActorId: string, ownerPosition: CombatPosition | null, now: number, ): { readonly demon: CombatSummon | null; readonly casterDamage: number; readonly demonDamage: number } { if (classId !== "warlock" || !ownerPosition) return { demon: null, casterDamage: amount, demonDamage: 0 }; const soulLinkRank = warlockTalentRank(talentRanks, "Soul Link", 1); if (soulLinkRank <= 0 || !auras.some((aura) => ( aura.sourceId === ownerActorId && aura.targetId === ownerActorId && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(WOW335_WARLOCK_SOUL_LINK_TAG) ))) return { demon: null, casterDamage: amount, demonDamage: 0 }; const demon = warlockSoulLinkDemon(summons, ownerActorId, ownerPosition, now); if (!demon) return { demon: null, casterDamage: amount, demonDamage: 0 }; const sharePercent = warlockSoulLinkSharePercent( soulLinkRank, warlockTalentRank(talentRanks, "Demonic Embrace", 3), ); const split = warlockSoulLinkDamageSplit(amount, sharePercent); // If the demon cannot survive the full share, only its remaining health is // transferred; the overflow stays with the caster. const demonDamage = Math.min(demon.health, split.demonDamage); return { demon, casterDamage: amount - demonDamage, demonDamage }; } function syncPassiveAuras( current: readonly ActiveAura[], abilities: readonly AbilityDefinition[], level: number, talentRanks: TalentRanks, now: number, ): ActiveAura[] { const definitions = abilities.flatMap((catalogAbility) => { if (!catalogAbility.passive || catalogAbility.name === "The Beast Within") return []; const ability = learnedAbilityForBuild(catalogAbility, level, talentRanks); if (!ability) return []; return ability.effects .filter((effect): effect is Extract => effect.kind === "apply-aura") .map((effect) => auraDefinitionForAbility(effect.aura, ability)); }); const passiveIds = new Set(definitions.map((definition) => definition.id)); let next = current.filter((aura) => ( aura.sourceId !== PLAYER_AURA_ENTITY_ID || !abilities.some((ability) => ability.passive && aura.definition.id.includes(`:${ability.dbcSpellId}:`)) || passiveIds.has(aura.definition.id) )); for (const definition of definitions) { if (next.some((aura) => aura.definition.id === definition.id && aura.targetId === PLAYER_AURA_ENTITY_ID)) continue; next = [...applyCombatAura(next, { definition, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } return next; } function spendDeathKnightBoneShieldCharge( auras: readonly ActiveAura[], triggered: readonly TriggeredAuraProc[], ): ActiveAura[] { let next = [...auras]; for (const proc of triggered) { if (proc.action.kind !== "custom" || proc.action.id !== "wow335-death-knight-bone-shield-charge" || (proc.event.amount ?? 0) <= 0) continue; const index = next.findIndex((aura) => aura.instanceId === proc.auraInstanceId && aura.definition.utilityTags?.includes("wow335-death-knight-bone-shield")); if (index < 0) continue; const aura = next[index]; if (aura.stacks <= 1) next = next.filter((entry) => entry.instanceId !== aura.instanceId); else next[index] = { ...aura, stacks: aura.stacks - 1 }; } return next; } function executeTriggeredAuraProcsInWork( work: MutableCastWork, triggeredProcs: readonly TriggeredAuraProc[], now: number, ): void { for (const triggered of triggeredProcs) { const action = triggered.action; if (work.classId === "mage" && action.kind === "custom" && action.id === "wow335-mage-focus-magic-proc") { // Focus Magic only reacts to a spell critical dealt by its recipient. // The aura proc is attached to the recipient, so `sourceId` is the Mage. if (triggered.sourceId === PLAYER_AURA_ENTITY_ID && triggered.event.trigger === "crit" && triggered.event.attackKind === "spell" && triggered.event.otherId && work.mobs[triggered.event.otherId]) { work.auras = [...applyCombatAura(work.auras, { definition: WOW335_MAGE_FOCUS_MAGIC_PROC_AURA, sourceId: triggered.sourceId, targetId: triggered.sourceId, now, }).auras]; } continue; } if (work.classId === "hunter" && action.kind === "custom" && action.id === "wow335-hunter-intimidation-hit") { const targetId = triggered.event.otherId; const mob = targetId ? work.mobs[targetId] : null; const intimidation = work.abilities.find((ability) => ability.dbcSpellId === 19577); if (mob && targetId && intimidation) { const controlled = applyStatus(mob, { kind: "status", status: "stun", durationMs: 3_000, }, intimidation.id, now, triggered.targetId); const threatByActor = { ...controlled.threatByActor, [triggered.targetId]: (controlled.threatByActor[triggered.targetId] ?? 0) + 580, }; work.mobs[targetId] = { ...controlled, threatByActor }; } work.auras = work.auras.filter((aura) => aura.instanceId !== triggered.auraInstanceId); continue; } if (work.classId === "rom-knight" && action.kind === "custom" && action.id === ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC) { const masterOfParryAura = work.auras.find((aura) => aura.instanceId === triggered.auraInstanceId); if (masterOfParryAura?.definition.utilityTags?.includes(ROM_KNIGHT_MASTER_OF_PARRY_TAG) && masterOfParryAura.procStates.some((state) => ( state.procId === ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC && state.remainingCharges === 0 ))) { work.auras = work.auras.filter((aura) => aura.instanceId !== triggered.auraInstanceId); } continue; } if (work.classId === "shaman" && action.kind === "custom" && action.id === "wow335-shamanistic-rage-mana") { if (triggered.targetId !== PLAYER_AURA_ENTITY_ID || (triggered.event.amount ?? 0) <= 0) continue; const percent = typeof action.data?.attackPowerPercent === "number" ? Math.max(0, action.data.attackPowerPercent) / 100 : 0.15; const shamanStats = applyAuraStats( playerCharacterStats( work.classId, work.progression.level, work.gearStats, work.raceId, work.secondaryClassId, work.talentModifiers, work.gender, work.statRuleset, ), aurasForEntity(work.auras, triggered.targetId), ); const requested = Math.max(0, Math.round(shamanStats.attackPower * percent)); const maximum = work.maxResourcePools.mana ?? defaultResourceMaximum("mana"); const current = work.resourcePools.mana ?? 0; const restored = Math.max(0, Math.min(requested, maximum - current)); if (restored <= 0) continue; work.resourcePools.mana = current + restored; if (resourceProfileForClass(work.classId).type === "mana") work.resource = work.resourcePools.mana; const rageAbilityId = work.abilities.find((ability) => ability.name === "Shamanistic Rage")?.id ?? triggered.procId; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: triggered.sourceId, targetActorId: triggered.targetId, abilityId: rageAbilityId, rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false, }); continue; } if (action.kind === "custom" && action.id === "wow335-death-knight-bone-shield-charge") { work.auras = spendDeathKnightBoneShieldCharge(work.auras, [triggered]); continue; } if (action.kind === "custom" && action.id === "wow335-prayer-of-mending") { const activeAura = work.auras.find((aura) => aura.instanceId === triggered.auraInstanceId); if (!activeAura) continue; const healAmount = typeof action.data?.healAmount === "number" ? Math.max(1, Math.round(action.data.healAmount)) : 0; const healTargetId = triggered.targetId; const healTarget = healTargetId === PLAYER_AURA_ENTITY_ID ? { health: work.health, maxHealth: work.maxHealth } : ensureCompanionActorInWork(work, healTargetId); const healed = healAmount > 0 && healTarget && healTarget.health > 0 ? healTargetId === PLAYER_AURA_ENTITY_ID ? healPlayerInWork(work, healAmount) : healCompanionActorInWork(work, healTargetId, healAmount) : 0; work.totalHealing += healed; if (healed > 0) recordCombatEventInWork(work, { occurredAt: now, kind: "healing", sourceActorId: triggered.sourceId, targetActorId: healTargetId, abilityId: "wow335-priest-prayer-of-mending", school: "holy", rawAmount: healAmount, effectiveAmount: healed, overheal: Math.max(0, healAmount - healed), absorbed: 0, critical: false, }); const remainingCharges = activeAura.procStates.find((state) => state.procId === triggered.procId) ?.remainingCharges ?? 0; work.auras = work.auras.filter((aura) => aura.sourceId !== triggered.sourceId || !isPrayerOfMendingAura(aura)); if (remainingCharges <= 0) continue; const origin = healTargetId === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(healTargetId); if (!origin) continue; const maxRange = typeof action.data?.maxRange === "number" ? Math.max(0, action.data.maxRange) : 40; const candidates = [ ...(work.health > 0 && work.health < work.maxHealth ? [{ id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: useCombatStore.getState().playerPosition }] : []), ...usePartyStore.getState().members.flatMap((member) => { const actor = work.actors[member.id] ?? ensureCompanionActorInWork(work, member.id); const position = getPartyRuntimePosition(member.id); return actor && actor.health > 0 && actor.health < actor.maxHealth && position ? [{ id: member.id, health: actor.health, maxHealth: actor.maxHealth, position }] : []; }), ].filter((candidate) => candidate.id !== healTargetId && distanceSquared(origin, candidate.position) <= maxRange * maxRange && combatHasLineOfSight(origin, candidate.position)) .sort((left, right) => ( left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth) || distanceSquared(origin, left.position) - distanceSquared(origin, right.position) || left.id.localeCompare(right.id) )); const nextTarget = candidates[0]; if (!nextTarget) continue; const definition: AuraDefinition = { ...activeAura.definition, procs: activeAura.definition.procs?.map((proc) => ( proc.action.kind === "custom" && proc.action.id === "wow335-prayer-of-mending" ? { ...proc, charges: remainingCharges } : proc )), }; work.auras = [...applyCombatAura(work.auras, { definition, sourceId: triggered.sourceId, targetId: nextTarget.id, now, }).auras]; continue; } if (action.kind === "custom" && action.id === "wow335-sweeping-strikes-secondary-hit") { const primaryTargetId = triggered.event.otherId; const primaryPosition = primaryTargetId ? getMobPosition(primaryTargetId) : null; if (triggered.event.attackKind !== "melee" || !primaryTargetId || !primaryPosition || !work.mobs[primaryTargetId] || work.mobs[primaryTargetId].dead || (triggered.event.amount ?? 0) <= 0) continue; const secondaryTargetId = Object.values(work.mobs) .filter((mob) => mob.id !== primaryTargetId && !mob.dead) .map((mob) => ({ id: mob.id, distanceSquared: mobRuntimeDistanceSquared(mob.id, primaryPosition), })) .filter((candidate) => candidate.distanceSquared <= 25) .sort((left, right) => left.distanceSquared - right.distanceSquared || left.id.localeCompare(right.id))[0]?.id; if (secondaryTargetId) { damageMobInWork( work.classId, work, secondaryTargetId, Math.max(0, triggered.event.amount ?? 0), now, playerThreatSource({ talentModifiers: work.talentModifiers }, triggered.procId, true), "physical", work.gearStats.spellPenetration, ); } continue; } if (action.kind === "custom" && action.id.startsWith("rom-")) { const triggeringAbility = triggered.event.abilityId ? work.abilities.find((ability) => ability.id === triggered.event.abilityId) ?? abilityById(triggered.event.abilityId) : null; if (action.id === ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID) { const data = action.data; const auraId = data?.auraId; const auraName = data?.auraName; const durationMs = data?.durationMs; const modifierValue = data?.modifierValue; if (work.classId === "rom-champion" && work.secondaryClassId === "rom-warlock" && triggered.sourceId === PLAYER_AURA_ENTITY_ID && triggered.event.trigger === "crit" && data?.sourceSpellId === ROM_CHAMPION_SOUL_FORGE_PROFILE.sourceSpellId && data.sourceComponentId === ROM_CHAMPION_SOUL_FORGE_PROFILE.darkPowerComponentId && data.target === "aura-source" && data.modifierKind === "damage" && data.modifierDirection === "dealt" && data.modifierSchool === ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicSchool && data.modifierOperation === "percent" && typeof auraId === "string" && typeof auraName === "string" && typeof durationMs === "number" && durationMs > 0 && typeof modifierValue === "number") { const definition: AuraDefinition = { id: auraId, name: auraName, disposition: "buff", durationMs, maxStacks: 1, stackBehavior: "refresh", modifiers: [{ kind: "damage", direction: "dealt", school: ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicSchool, operation: "percent", value: modifierValue, }], }; work.auras = [...applyCombatAura(work.auras, { definition, sourceId: triggered.sourceId, targetId: triggered.sourceId, now, }).auras]; } continue; } if (action.id === ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID) { const data = action.data; const durationMs = data?.durationMs; const maxStacks = data?.maxStacks; const staminaPercentPerStack = data?.staminaPercentPerStack; if (work.classId === "rom-champion" && work.secondaryClassId === "rom-priest" && triggered.sourceId === PLAYER_AURA_ENTITY_ID && (triggered.event.trigger === "hit" || triggered.event.trigger === "crit") && triggeringAbility?.dbcSpellId === ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerSpellId && data?.sourceSpellId === ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId && data.sourceComponentId === ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceComponentId && typeof durationMs === "number" && durationMs > 0 && maxStacks === ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.maxStacks && typeof staminaPercentPerStack === "number" && staminaPercentPerStack > 0) { const definition: AuraDefinition = { id: "rom-champion-way-of-frenzy-stacks", name: "Way of Frenzy", description: `Each landed Divine Vengeance hit grants ${Math.round(staminaPercentPerStack * 100)}% Stamina for ${durationMs / 1_000} seconds, stacking up to ${maxStacks} times.`, disposition: "buff", durationMs, maxStacks, stackBehavior: "add", modifiers: [{ kind: "stat", stat: "stamina", operation: "percent", value: staminaPercentPerStack, perStack: true, }], }; work.auras = [...applyCombatAura(work.auras, { definition, sourceId: triggered.sourceId, targetId: triggered.sourceId, now, }).auras]; } continue; } if (action.id === ROM_PRIEST_CHAMPION_REGENERATE_PROC_ACTION_ID) { if (triggered.sourceId !== triggered.targetId) continue; const partyMember = usePartyStore.getState().members.find((member) => member.id === triggered.sourceId); const isPlayer = triggered.sourceId === PLAYER_AURA_ENTITY_ID; if (!partyMember && !isPlayer) continue; const primaryClassId = partyMember?.classId ?? work.classId; const secondaryClassId = partyMember?.secondaryClassId ?? work.secondaryClassId; const level = partyMember?.level ?? work.progression.level; const talentRanks = partyMember?.talentRanks ?? work.talentRanks; const sourceAbilities = abilitiesForCharacter(primaryClassId, secondaryClassId); const learnedPassiveSpellIds = sourceAbilities.filter((ability) => ability.source === "runewaker" && ability.classId === "rom-priest" && ability.secondaryClassId === "rom-champion" && ability.passive && ability.dbcSpellId === ROM_PRIEST_CHAMPION_PROTECTION_RUNE_SEAL_SPELL_ID && learnedAbilityForBuild(ability, level, talentRanks) !== null) .map((ability) => ability.dbcSpellId); if (!romPriestChampionProtectionRuneSealProcIsEligible({ primaryClassId, secondaryClassId, learnedPassiveSpellIds, trigger: triggered.event.trigger, triggeringAbilitySpellId: triggeringAbility?.dbcSpellId, healingAmount: triggered.event.amount, })) continue; const heartRuneEnergy = sourceAbilities.find((ability) => ability.source === "runewaker" && ability.classId === "rom-priest" && ability.secondaryClassId === "rom-champion" && ability.dbcSpellId === ROM_PRIEST_CHAMPION_HEART_RUNE_ENERGY_SPELL_ID && learnedAbilityForBuild(ability, level, talentRanks) !== null); if (!heartRuneEnergy) continue; if (partyMember) { usePartyStore.getState().updateMemberCombat(partyMember.id, { cooldowns: { ...partyMember.cooldowns, [heartRuneEnergy.id]: 0 }, }); const actor = work.actors[partyMember.id]; if (actor) { work.actors[partyMember.id] = { ...actor, cooldowns: { ...actor.cooldowns, [heartRuneEnergy.id]: 0 }, }; } } else { work.cooldowns[heartRuneEnergy.id] = 0; } continue; } if (action.id === "rom-champion-forge-hit" || action.id === ROM_CHAMPION_FORGE_RANK_TWO_PROC_ACTION_ID) { const rankTwo = action.id === ROM_CHAMPION_FORGE_RANK_TWO_PROC_ACTION_ID; const ownerId = triggered.sourceId; const isPlayer = ownerId === PLAYER_AURA_ENTITY_ID; const partyMember = isPlayer ? undefined : usePartyStore.getState().members.find((member) => member.id === ownerId); const primaryClassId = isPlayer ? work.classId : partyMember?.classId; const secondaryClassId = isPlayer ? work.secondaryClassId : partyMember?.secondaryClassId; const level = isPlayer ? work.progression.level : partyMember?.level; const talentRanks = isPlayer ? work.talentRanks : partyMember?.talentRanks; if (primaryClassId !== "rom-champion" || triggered.sourceId !== triggered.targetId || (triggered.event.trigger !== "hit" && triggered.event.trigger !== "crit") || triggered.event.attackKind !== "melee" || triggered.event.isAutoAttack !== true || (triggered.event.amount ?? 0) <= 0 || (rankTwo ? triggered.auraId !== ROM_CHAMPION_FORGE_RANK_TWO_AURA_ID || !romChampionHasForgeRankTwoAura(work.auras, ownerId, now) : triggered.auraId !== "rom-champion-forge-active")) continue; const sourceAbilities = abilitiesForCharacter(primaryClassId, secondaryClassId); const forgeBase = sourceAbilities.find((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-champion" && candidate.dbcSpellId === 498523 && !candidate.passive); const learnedForgeRank = forgeBase && level !== undefined && talentRanks ? learnedAbilityForBuild(forgeBase, level, talentRanks) : null; const learnedChainDrive = sourceAbilities.some((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-champion" && candidate.dbcSpellId === 498535 && learnedAbilityForBuild(candidate, level ?? 0, talentRanks ?? {}) !== null); if (rankTwo && (learnedForgeRank?.dbcSpellId !== ROM_CHAMPION_FORGE_SOURCE_SKILL_ID || !learnedChainDrive)) continue; work.auras = [...applyCombatAura(work.auras, { definition: ROM_CHAMPION_FORGE_STACK_AURA, sourceId: ownerId, targetId: ownerId, now, }).auras]; if (rankTwo) { const hasShieldForm = romChampionHasSourceComponentAura( work.auras, ownerId, ROM_CHAMPION_FORGE_SHIELD_FORM_COMPONENT_ID, now, ); let rollIndex = 0; const hitSeed = triggered.event.rollSeed ?? `${ownerId}:${triggered.event.otherId ?? "target"}:${now}:rom-champion-forge`; const chainDriveProc = romChampionForgeProcSucceeds(hasShieldForm, () => stableCombatUnit( `${hitSeed}:498590:${rollIndex++}`, )); if (chainDriveProc) { work.auras = [...applyCombatAura(work.auras, { definition: ROM_CHAMPION_CHAIN_DRIVE_WINDOW_AURA, sourceId: ownerId, targetId: ownerId, now, }).auras]; const resetSpellIds = secondaryClassId === "rom-rogue" ? [498529, 498726] : [498529]; const resetAbilities = resetSpellIds.flatMap((spellId) => sourceAbilities .filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-champion" && candidate.dbcSpellId === spellId) .map((candidate) => learnedAbilityForBuild(candidate, level ?? 0, talentRanks ?? {})) .filter((candidate): candidate is AbilityDefinition => candidate !== null)); if (isPlayer) { for (const resetAbility of resetAbilities) work.cooldowns[resetAbility.id] = 0; } else { const actor = ensureCompanionActorInWork(work, ownerId); if (actor) { const nextActor = { ...actor, cooldowns: { ...actor.cooldowns, ...Object.fromEntries(resetAbilities.map((resetAbility) => [resetAbility.id, 0])), }, }; work.actors[ownerId] = nextActor; mirrorCompanionActor(nextActor); } } } } if (isPlayer) { const rage = gainRomRage( primaryClassId, secondaryClassId, 10, work.resource, work.maxResource, work.resourcePools, work.maxResourcePools, ); work.resource = rage.resource; work.resourcePools = rage.resourcePools; } else if (partyMember) { const actor = ensureCompanionActorInWork(work, ownerId); if (actor) { const ragePool = actor.resources.rage ?? normalizeActorResourcePool("rage", 0, 100, 0, now); const nextActor = { ...actor, resources: { ...actor.resources, rage: { ...ragePool, current: Math.min(ragePool.maximum, ragePool.current + 10) }, }, }; work.actors[ownerId] = nextActor; mirrorCompanionActor(nextActor); } } continue; } if (action.id === "rom-cripple-joint") { const targetId = triggered.event.otherId ?? triggered.targetId; const target = work.mobs[targetId]; if (triggeringAbility?.dbcSpellId === 490420 && target) { work.mobs[targetId] = applyStatus(target, { kind: "status", status: "root", durationMs: 5_000, maxTargets: 1, breakOnDamage: true, }, triggeringAbility.id, now, triggered.sourceId); } continue; } const poisonous = work.auras.some((aura) => aura.targetId === triggered.sourceId && aura.definition.utilityTags?.includes("rom-poisonous")); const spec = romScoutRogueProcActionSpec(action.id, triggeringAbility?.dbcSpellId ?? 0, poisonous, action.data); if (spec) { if (action.id === "rom-sinister-methods") { if (work.classId !== "rom-rogue" || work.secondaryClassId !== "rom-priest" || triggered.sourceId !== PLAYER_AURA_ENTITY_ID) continue; const readyAt = work.cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY] ?? now; if (readyAt > now) continue; if (spec.kind !== "aura" || !spec.cooldownMs) continue; work.cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY] = now + spec.cooldownMs; } const recipient = spec.recipient === "caster" ? triggered.sourceId : triggered.event.otherId ?? triggered.targetId; if (spec.kind === "aura" && recipient) { work.auras = [...applyCombatAura(work.auras, { definition: spec.aura, sourceId: triggered.sourceId, targetId: recipient, now, }).auras]; } else if (spec.kind === "resource" && recipient === PLAYER_AURA_ENTITY_ID) { const maximum = work.maxResourcePools[spec.resource] ?? defaultResourceMaximum(spec.resource); work.resourcePools[spec.resource] = clamp( (work.resourcePools[spec.resource] ?? 0) + spec.amount, 0, maximum, ); } else if (spec.kind === "resource" && recipient && work.actors[recipient]) { const actor = work.actors[recipient]; const pool = actor.resources[spec.resource]; if (pool) { const next = { ...actor, resources: { ...actor.resources, [spec.resource]: { ...pool, current: clamp(pool.current + spec.amount, 0, pool.maximum) }, } }; work.actors[recipient] = next; mirrorCompanionActor(next); } } else if (spec.kind === "status" && recipient && work.mobs[recipient]) { work.mobs[recipient] = applyStatus(work.mobs[recipient]!, { kind: "status", status: spec.status, durationMs: spec.durationMs, }, triggeringAbility?.id ?? triggered.procId, now, triggered.sourceId); } else if (spec.kind === "dot" && recipient && work.mobs[recipient]) { work.effects = work.effects.filter((effect) => !(effect.kind === "dot" && effect.sourceActorId === triggered.sourceId && effect.targetId === recipient && effect.tag === spec.effect.tag)); work.effects.push(timedEffect( "dot", triggeringAbility?.id ?? triggered.procId, triggered.sourceId, recipient, spec.effect, spec.effect.ticks, spec.effect.intervalMs, now, spec.effect.tag, spec.effect.school ?? "nature", work.gearStats.spellPenetration, )); } continue; } } const actionTarget = action.kind === "custom" ? null : action.target === "aura-source" ? triggered.sourceId : action.target === "aura-target" ? triggered.targetId : triggered.event.otherId ?? null; const sourceShamanRanks = shamanTalentRanksForActor(work, triggered.sourceId); const improvedShieldsRank = sourceShamanRanks ? shamanTalentRankFromRanks(sourceShamanRanks, "Improved Shields", 3) : 0; const improvedEarthShieldRank = sourceShamanRanks ? shamanTalentRankFromRanks(sourceShamanRanks, "Improved Earth Shield", 3) : 0; const isShamanLightningShieldProc = action.kind === "damage" && triggered.procId.endsWith(":lightning-shield") && sourceShamanRanks !== null; const isShamanEarthShieldProc = action.kind === "heal" && triggered.procId.endsWith(":earth-shield") && sourceShamanRanks !== null; const procAmount = isShamanLightningShieldProc && action.kind === "damage" ? wow335ShamanImprovedShieldOrbAmount( action.amount, "lightning-shield", improvedShieldsRank, Boolean(actionTarget && work.mobs[actionTarget]?.characterDefender), ) : isShamanEarthShieldProc && action.kind === "heal" ? wow335ShamanImprovedEarthShieldHealingAmount( action.amount, improvedEarthShieldRank, improvedShieldsRank, ) : action.kind === "damage" || action.kind === "heal" ? action.amount : 0; if (action.kind === "resource" && actionTarget === PLAYER_AURA_ENTITY_ID) { const resource = action.resource as ResourceType; const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource); work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum); } else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) { const healed = healPlayerInWork(work, procAmount); work.totalHealing += healed; if (isShamanEarthShieldProc) { recordCombatEventInWork(work, { occurredAt: now, kind: "healing", sourceActorId: triggered.sourceId, targetActorId: actionTarget, abilityId: triggered.procId, school: "nature", rawAmount: procAmount, effectiveAmount: healed, overheal: Math.max(0, procAmount - healed), absorbed: 0, critical: false, outcome: healed > 0 ? "hit" : "overheal", }); maybeGrantShamanEarthShieldTidalWavesInWork(work, triggered.sourceId, now); } } else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) { const healed = healCompanionActorInWork(work, actionTarget, Math.max(0, procAmount)); work.totalHealing += healed; if (isShamanEarthShieldProc) { recordCombatEventInWork(work, { occurredAt: now, kind: "healing", sourceActorId: triggered.sourceId, targetActorId: actionTarget, abilityId: triggered.procId, school: "nature", rawAmount: procAmount, effectiveAmount: healed, overheal: Math.max(0, procAmount - healed), absorbed: 0, critical: false, outcome: healed > 0 ? "hit" : "overheal", }); maybeGrantShamanEarthShieldTidalWavesInWork(work, triggered.sourceId, now); } } else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) { const triggeredAura = work.auras.find((aura) => aura.instanceId === triggered.auraInstanceId); const damageSource = isShamanLightningShieldProc ? threatSourceForActor({ talentModifiers: work.talentModifiers }, triggered.sourceId, triggered.procId, true) : triggeredAura && isWow335PaladinRetributionAuraDamageShield(triggeredAura) ? threatSourceForActor({ talentModifiers: work.talentModifiers }, triggered.sourceId, triggered.procId, true) : playerThreatSource({ talentModifiers: work.talentModifiers }, triggered.procId, true); damageMobInWork( work.classId, work, actionTarget, Math.max(0, procAmount), now, damageSource, action.school, ); } else if (action.kind === "apply-aura" && actionTarget) { const definition = work.auras.find((aura) => aura.definition.id === action.auraId)?.definition; if (definition) { work.auras = [...applyCombatAura(work.auras, { definition, sourceId: triggered.sourceId, targetId: actionTarget, now, }).auras]; } } else if ( action.kind === "custom" && (action.id === "wow335-trigger-spell" || action.id === "rom-trigger-skill") && typeof action.data?.spellId === "number" ) { const spellId = action.data.spellId; const targetId = triggered.event.otherId ?? triggered.targetId; const triggerKey = `${spellId}:${triggered.sourceId}:${targetId}`; const triggeredSpellStack = work.triggeredSpellStack ?? []; if ( triggeredSpellStack.length >= MAX_TRIGGERED_SPELL_CHAIN_DEPTH || triggeredSpellStack.includes(triggerKey) ) continue; triggeredSpellStack.push(triggerKey); work.triggeredSpellStack = triggeredSpellStack; try { executeTriggeredSpellInWork( work, spellId, triggered.sourceId, targetId, now, 0, true, ); } finally { triggeredSpellStack.pop(); if (triggeredSpellStack.length === 0) work.triggeredSpellStack = undefined; } } else if ( action.kind === "custom" && action.id === "classic-status" && typeof action.data?.status === "string" ) { const targetId = triggered.event.otherId ?? triggered.targetId; const mob = work.mobs[targetId]; if (mob) { work.mobs[targetId] = applyStatus(mob, { kind: "status", status: action.data.status as MobStatusKind, durationMs: typeof action.data.durationMs === "number" ? action.data.durationMs : 3_000, ...(typeof action.data.magnitude === "number" ? { magnitude: action.data.magnitude } : {}), }, `proc:${triggered.procId}`, now); } } else if (action.kind === "custom" && action.id === "rom-shield-discipline-reflect") { const attackerId = triggered.event.otherId; if (triggered.event.attackKind === "spell" && attackerId && work.mobs[attackerId]) { damageMobInWork(work.classId, work, attackerId, 100, now, { actorId: triggered.sourceId, role: "tank", abilityId: "rom-knight-general-7", }, "holy"); } } else if (action.kind === "custom" && action.id === "rom-frost-arrow") { const triggeringAbility = triggered.event.abilityId ? abilityById(triggered.event.abilityId) : null; const targetId = triggered.event.otherId ?? triggered.targetId; const mob = work.mobs[targetId]; if (mob && triggeringAbility?.name === "Wind Arrow") { work.mobs[targetId] = applyStatus(mob, { kind: "status", status: "slow", durationMs: 6_000, magnitude: 0.4, }, `proc:${triggered.procId}`, now); } } else if (action.kind === "custom" && action.id === "rom-entling-offering") { const triggeringAbility = triggered.event.abilityId ? abilityById(triggered.event.abilityId) : null; const targetId = triggered.event.otherId ?? triggered.targetId; if (work.mobs[targetId] && triggeringAbility?.name === "Shot") { damageMobInWork( work.classId, work, targetId, Math.max(1, Math.round(playerCombatPower(work.progression.level) * 0.35)), now, playerThreatSource({ talentModifiers: work.talentModifiers }, "rom-entling-offering", true), "physical", ); } } } } function maybeProcHunterLockAndLoadInWork( work: MutableCastWork, sourceActorId: string, sourceAbilityId: string, abilityName: string, trigger: "trap" | "periodic", targetId: string, now: number, ): boolean { const playerSource = sourceActorId === PLAYER_AURA_ENTITY_ID; const partyMember = playerSource ? undefined : usePartyStore.getState().members.find((member) => member.id === sourceActorId); const ownerClassId = playerSource ? work.classId : partyMember?.classId; const talentRanks = playerSource ? work.talentRanks : partyMember?.talentRanks; if (ownerClassId !== "hunter" || !talentRanks) return false; const periodicAbilities = new Set(["Immolation Trap", "Explosive Trap", "Black Arrow"]); if ((trigger === "trap" && !["Freezing Arrow", "Freezing Trap"].includes(abilityName)) || (trigger === "periodic" && !periodicAbilities.has(abilityName))) return false; const rank = Math.max(0, Math.min(3, Math.trunc(talentRanks["talent-1306"] ?? 0))); const mechanics = rank > 0 ? lockAndLoadRankMechanics(rank) : null; if (!mechanics) return false; const ownerId = playerSource ? PLAYER_AURA_ENTITY_ID : sourceActorId; if (work.auras.some((aura) => aura.targetId === ownerId && aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG) && (aura.expiresAt === null || aura.expiresAt > now))) return false; const chance = trigger === "trap" ? mechanics.trapProcChance : mechanics.periodicProcChance; if (chance <= 0 || stableCombatUnit( `${ownerId}:${sourceAbilityId}:${trigger}:${targetId}:${now}:wow335-hunter-lock-and-load`, ) >= chance) return false; work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:hunter:lock-and-load-ready", name: "Lock and Load", description: mechanics.description, disposition: "buff", durationMs: mechanics.durationMs, maxStacks: mechanics.freeCastCount, initialStacks: mechanics.freeCastCount, stackBehavior: "replace", utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG], }, sourceId: ownerId, targetId: ownerId, now, }).auras]; work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:hunter:lock-and-load-icd", name: "Lock and Load Internal Cooldown", description: mechanics.description, disposition: "buff", durationMs: mechanics.internalCooldownMs, utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG], }, sourceId: ownerId, targetId: ownerId, now, }).auras]; return true; } function evaluateProcsInWork( work: MutableCastWork, event: ProcEvent, now: number, retributionAuraSourceSpellPower?: (sourceId: string) => number, ): readonly TriggeredAuraProc[] { const root = work.triggeredProcKeys === undefined; const triggeredKeys = work.triggeredProcKeys ?? new Set(); work.triggeredProcKeys = triggeredKeys; try { const originalAuras = new Map(work.auras.map((aura) => [aura.instanceId, aura] as const)); const procSourceAuras = retributionAuraSourceSpellPower ? work.auras.map((aura) => aura.targetId === event.subjectId && isWow335PaladinRetributionAuraDamageShield(aura) ? wow335PaladinRetributionAuraWithSpellPower( aura, retributionAuraSourceSpellPower(aura.sourceId), ) : aura) : work.auras; const auraOverrides = new Map(aurasForEntity(procSourceAuras, event.subjectId, now) .map((aura) => [aura.instanceId, aura] as const)); const procInputAuras = work.auras.map((aura) => auraOverrides.get(aura.instanceId) ?? aura); const result = evaluateAuraProcs( procInputAuras, event, now, ({ aura, proc }) => triggeredKeys.has(`${aura.instanceId}:${proc.id}`) ? 1 : stableCombatUnit( `${aura.instanceId}:${proc.id}:${event.trigger}:${event.abilityId ?? ""}:${event.otherId ?? ""}:${now}`, ), ); // A proc can activate once for the originating event. Its damage must not // reactivate it through a different branch of the same triggered chain. for (const proc of result.triggered) triggeredKeys.add(`${proc.auraInstanceId}:${proc.procId}`); work.auras = result.auras.map((aura) => { const original = originalAuras.get(aura.instanceId); return original ? { ...original, procStates: aura.procStates } : aura; }); executeTriggeredAuraProcsInWork(work, result.triggered, now); return result.triggered; } finally { if (root) work.triggeredProcKeys = undefined; } } function isPrayerOfMendingAura(aura: ActiveAura): boolean { return aura.definition.procs?.some((proc) => ( proc.action.kind === "custom" && proc.action.id === "wow335-prayer-of-mending" )) ?? false; } function evaluatePartyRetributionAuraInWork( auras: readonly ActiveAura[], event: ProcEvent, now: number, sourceSpellPower: (sourceId: string) => number, ): { readonly auras: ActiveAura[]; readonly triggered: readonly TriggeredAuraProc[] } { const procSourceAuras = auras.map((aura) => aura.targetId === event.subjectId && isWow335PaladinRetributionAuraDamageShield(aura) ? wow335PaladinRetributionAuraWithSpellPower(aura, sourceSpellPower(aura.sourceId)) : aura); const auraOverrides = new Map(aurasForEntity(procSourceAuras, event.subjectId, now) .filter(isWow335PaladinRetributionAuraDamageShield) .map((aura) => [aura.instanceId, aura] as const)); if (auraOverrides.size === 0) return { auras: [...auras], triggered: [] }; const result = evaluateAuraProcs( [...auraOverrides.values()], event, now, ({ aura, proc }) => stableCombatUnit( `${aura.instanceId}:${proc.id}:${event.trigger}:${event.abilityId ?? ""}:${event.otherId ?? ""}:${now}`, ), ); const evaluatedByInstance = new Map(result.auras.map((aura) => [aura.instanceId, aura] as const)); return { auras: auras.map((aura) => { const evaluated = evaluatedByInstance.get(aura.instanceId); return evaluated ? { ...aura, procStates: evaluated.procStates } : aura; }), triggered: result.triggered, }; } /** Companion damage only needs this spell's proc; keep unrelated companion procs unchanged. */ function evaluatePrayerOfMendingInWork(work: MutableCastWork, event: ProcEvent, now: number): void { const candidates = work.auras.filter((aura) => isPrayerOfMendingAura(aura)); if (!candidates.length) return; const candidateIds = new Set(candidates.map((aura) => aura.instanceId)); const result = evaluateAuraProcs( candidates, event, now, ({ aura, proc }) => stableCombatUnit( `${aura.instanceId}:${proc.id}:${event.trigger}:${event.abilityId ?? ""}:${event.otherId ?? ""}:${now}`, ), ); work.auras = [ ...work.auras.filter((aura) => !candidateIds.has(aura.instanceId)), ...result.auras, ]; executeTriggeredAuraProcsInWork(work, result.triggered, now); } function isShamanShieldProcAura(work: MutableCastWork, aura: ActiveAura): boolean { if (!shamanTalentRanksForActor(work, aura.sourceId)) return false; return aura.definition.procs?.some((proc) => ( proc.id.endsWith(":lightning-shield") || proc.id.endsWith(":earth-shield") )) ?? false; } function evaluatePartyShamanShieldProcsInWork(work: MutableCastWork, event: ProcEvent, now: number): void { const candidates = work.auras.filter((aura) => aura.targetId === event.subjectId && isShamanShieldProcAura(work, aura)); if (!candidates.length) return; const result = evaluateAuraProcs( candidates, event, now, ({ aura, proc }) => stableCombatUnit( `${aura.instanceId}:${proc.id}:${event.trigger}:${event.abilityId ?? ""}:${event.otherId ?? ""}:${now}`, ), ); const evaluatedByInstance = new Map(result.auras.map((aura) => [aura.instanceId, aura] as const)); work.auras = work.auras.map((aura) => { const evaluated = evaluatedByInstance.get(aura.instanceId); return evaluated ? { ...aura, procStates: evaluated.procStates } : aura; }); executeTriggeredAuraProcsInWork(work, result.triggered, now); } function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] { return nearbyMobRuntimeIds(origin, maxRange).filter((id) => { const mob = state.mobs[id]; const position = getMobPosition(id); return Boolean(mob && !mob.dead && position && combatHasLineOfSight(origin, position)); }); } function castFailureMessage(reason: CastFailureReason, resourceName: string, unlockLevel?: number): string { switch (reason) { case "unknown-ability": return "That ability is unavailable."; case "wrong-class": return "Your class cannot use that ability."; case "not-learned": return "Learn that ability in the Conquest talent tree first."; case "passive": return "Passive abilities activate automatically and cannot be cast."; case "unsupported": return "That spell has no executable combat effect."; case "level-locked": return `That ability unlocks at level ${unlockLevel ?? "a higher level"}.`; case "player-dead": return "You cannot act while defeated."; case "player-controlled": return "You cannot act while under an enemy control effect."; case "already-casting": return "Another spell is already being cast."; case "global-cooldown": return "Your abilities are not ready yet."; case "cooldown": return "That ability is still recharging."; case "not-enough-resource": return `Not enough ${resourceName}.`; case "not-enough-runes": return "The required Death Knight runes are still recharging."; case "condition-not-met": return "That ability's required RuneWaker state is not active."; case "no-target": return "Select an enemy target."; case "target-dead": return "That target is already defeated."; case "target-unavailable": return "That target is not currently visible."; case "line-of-sight": return "That target is not in line of sight."; case "not-facing": return "You must face that target."; case "must-be-behind": return "You must be behind that target."; case "invalid-destination": return "That ground destination is invalid."; case "school-locked": return "That spell school is locked out."; case "too-close": return "That target is too close."; case "out-of-range": return "That target is out of range."; } } function levelUpMessage(classId: ClassId, previousLevel: number, currentLevel: number): string { const parts = [`Level ${currentLevel}!`]; const unlocked = abilitiesUnlockedBetweenLevels(classId, previousLevel, currentLevel); if (unlocked.length === 1) { parts.push(`New ability unlocked: ${unlocked[0].name}.`); } else if (unlocked.length > 1) { parts.push(`New abilities unlocked: ${unlocked.map((ability) => ability.name).join(", ")}.`); } if (talentPointsForLevel(currentLevel) > talentPointsForLevel(previousLevel)) { parts.push("A new talent point is available."); } return parts.join(" "); } function progressionFromState(state: CombatState): MutableProgression { return { level: state.level, xp: state.xp, xpToNext: state.xpToNext, health: state.health, maxHealth: state.maxHealth, levelsGained: 0, }; } function grantMobExperience( classId: ClassId, work: MutableCastWork, xpReward: number, ): number { const { progression } = work; const scaledAward = healerManExperienceAward(xpReward, progression.level); const result = awardExperience({ level: progression.level, xp: progression.xp }, scaledAward); progression.level = result.level; progression.xp = result.xp; progression.xpToNext = result.xpToNext; progression.levelsGained += result.levelsGained; if (result.levelsGained > 0) { work.talentModifiers = characterStatModifiers(classId, result.level, work.talentRanks); work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow()); progression.maxHealth = auraAdjustedPlayerMaxHealth( classId, result.level, work.talentModifiers, work.gearStats, work.raceId, work.secondaryClassId, work.auras, work.gender, work.statRuleset, ); // A lingering DoT may grant experience after death, but leveling is not resurrection. progression.health = work.health > 0 ? progression.maxHealth : 0; work.health = progression.health; work.maxHealth = progression.maxHealth; const maxResourceBase = talentAdjustedMaxResource( classId, result.level, work.talentModifiers, work.gearStats, work.raceId, work.secondaryClassId, work.gender, work.statRuleset, ); const nativeManaMaximum = nativeManaPoolMaximum( classId, result.level, work.talentModifiers, work.gearStats, work.raceId, work.secondaryClassId, work.gender, work.statRuleset, work.auras, ); if (nativeManaMaximum !== null) { const resized = resizeManaPool(work.resourcePools, work.maxResourcePools, nativeManaMaximum, true); work.resourcePools = resized.pools; work.maxResourcePools = resized.maximums; if (resourceProfileForClass(classId).type === "mana") { work.maxResource = nativeManaMaximum; work.resource = resized.pools.mana ?? 0; } else { work.maxResource = maxResourceBase; } } else { work.maxResource = maxResourceBase; } if (work.maxResourcePools["nature-power"] !== undefined) { work.maxResourcePools = { ...work.maxResourcePools, "nature-power": naturePowerMaximumForCharacter(classId, work.secondaryClassId, result.level), }; } } return scaledAward; } function mobGroupId(id: string): string { if (id.startsWith("manastorm:guardian-spawn:")) { const memberSeparator = id.lastIndexOf(":"); return memberSeparator > 0 ? id.slice(0, memberSeparator) : id; } // A Manastorm boss shares an aura with guardians, not their aggro group. // Keeping its full runtime id lets players choose to pull it early without // every guardian hit dragging the boss across the stage. if (id.startsWith("manastorm:spawn:")) return id; const separator = id.indexOf(":"); return separator > 0 ? id.slice(0, separator) : id; } function engageMobGroup( mobs: Record, sourceId: string, now: number, sourceActorId: AggroActorId, ): boolean { const groupId = mobGroupId(sourceId); const groupWasEngaged = Object.entries(mobs).some(([id, mob]) => ( mobGroupId(id) === groupId && mob.engaged && !mob.dead )); let changed = false; for (const [id, mob] of Object.entries(mobs)) { if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue; if (mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue; const threatByActor = groupWasEngaged ? mob.threatByActor : seedActorThreat(mob.threatByActor, sourceActorId); mobs[id] = { ...mob, threatByActor, targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId), engaged: true, combatPhase: "chasing", homePosition: groupWasEngaged ? (mob.homePosition ?? getMobPosition(id)) : (getMobPosition(id) ?? mob.homePosition), abilityReadyAt: Object.keys(mob.abilityReadyAt).length ? mob.abilityReadyAt : initialEnemyAbilityReadyAt(mob, now), nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450, }; changed = true; } return changed; } function engageMobGroupInWork( work: MutableCastWork, sourceId: string, now: number, sourceActorId: AggroActorId, ): void { engageMobGroup( work.mobs, sourceId, now, activeTricksThreatRecipient(work.auras, sourceActorId, now) ?? sourceActorId, ); } function nearestAggroActorId( state: Pick, mobPosition: CombatPosition, aggroRange: number, ): AggroActorId | null { if (!Number.isFinite(aggroRange) || aggroRange <= 0) return null; let nearestId: AggroActorId | null = null; let nearestDistanceSquared = aggroRange * aggroRange; if (state.health > 0) { const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition); if (playerDistanceSquared <= nearestDistanceSquared && combatHasLineOfSight(mobPosition, state.playerPosition)) { nearestId = PLAYER_AGGRO_ID; nearestDistanceSquared = playerDistanceSquared; } } for (const member of usePartyStore.getState().members) { if (member.health <= 0) continue; const position = getPartyRuntimePosition(member.id); if (!position) continue; const memberDistanceSquared = distanceSquared(mobPosition, position); if (memberDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) { nearestId = member.id; nearestDistanceSquared = memberDistanceSquared; } } for (const actor of onlinePlayerRuntimeActors()) { if (actor.health <= 0) continue; const position = getPartyRuntimePosition(actor.id); if (!position) continue; const actorDistanceSquared = distanceSquared(mobPosition, position); if (actorDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) { nearestId = actor.id; nearestDistanceSquared = actorDistanceSquared; } } for (const summon of state.summons) { if (summon.health <= 0) continue; const summonDistanceSquared = distanceSquared(mobPosition, summon.position); if (summonDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, summon.position)) { nearestId = summon.id; nearestDistanceSquared = summonDistanceSquared; } } return nearestId; } function mobResistanceForSchool( mob: MobCombatState, school: DamageSchool, ): number { if (school === "arcane") return mob.resistances.arcane; if (school === "fire") return mob.resistances.fire; if (school === "frost") return mob.resistances.frost; if (school === "nature") return mob.resistances.nature; if (school === "shadow") return mob.resistances.shadow; return 0; } function damageAfterMobResistance( amount: number, mob: MobCombatState, school: DamageSchool, spellPenetration: number, attackerLevel: number, ): number { if (mob.schoolImmunities.includes(school)) return 0; if (school === "physical" || school === "holy") return amount; const resistance = Math.max(0, mobResistanceForSchool(mob, school) - Math.max(0, spellPenetration)); const resisted = resistance / Math.max(1, resistance + Math.max(1, attackerLevel) * 5) * 0.75; return amount * (1 - Math.min(0.75, resisted)); } const DRUID_CLEARCASTING_AURA: AuraDefinition = { id: "wow335:druid:clearcasting", name: "Clearcasting", disposition: "buff", durationMs: 15_000, maxStacks: 1, stackBehavior: "refresh", utilityTags: ["wow335-druid-clearcasting"], modifiers: [{ kind: "resource", resource: "power", property: "cost", operation: "percent", value: -1, }], }; const DRUID_NATURES_GRACE_AURA: AuraDefinition = { id: "wow335:druid:natures-grace", name: "Nature's Grace", disposition: "buff", durationMs: 4_000, maxStacks: 1, stackBehavior: "refresh", utilityTags: ["wow335-druid-natures-grace"], modifiers: [{ kind: "stat", stat: "haste", operation: "flat", value: 20 }], }; const DRUID_LIVING_SEED_AURA: AuraDefinition = { id: "wow335:druid:living-seed", name: "Living Seed", description: "Stores 30% of an eligible critical heal and blooms when the target next takes direct damage.", disposition: "buff", durationMs: 15_000, maxStacks: 1, stackBehavior: "replace", utilityTags: ["wow335-druid-living-seed"], }; const DRUID_LIVING_SEED_ABILITY_ID = "wow335:druid:living-seed"; const DRUID_LIVING_SEED_ELIGIBLE_HEALS = new Set(["Healing Touch", "Regrowth", "Swiftmend"]); function maybeProcWarriorImprovedVictoryRushOnParry( auras: readonly ActiveAura[], actorId: string, rank: number, parried: boolean, seed: string, now: number, ): ActiveAura[] { const internalCooldownActive = auras.some((aura) => ( aura.targetId === actorId && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_COOLDOWN_TAG) )); if (!wow335WarriorImprovedVictoryRushShouldProc( rank, parried, internalCooldownActive, stableCombatUnit(`${seed}:wow335-warrior-improved-victory-rush`) * 100, )) return [...auras]; const readyDefinition = wow335WarriorImprovedVictoryRushReadyAuraDefinition(rank); const cooldownDefinition = wow335WarriorImprovedVictoryRushCooldownAuraDefinition(rank); if (!readyDefinition || !cooldownDefinition) return [...auras]; const readyAuras = applyCombatAura(auras, { definition: readyDefinition, sourceId: actorId, targetId: actorId, now, }).auras; return [...applyCombatAura(readyAuras, { definition: cooldownDefinition, sourceId: actorId, targetId: actorId, now, }).auras]; } function maybeProcWarriorImprovedDefensiveStance( auras: readonly ActiveAura[], actorId: string, rank: number, trigger: Wow335WarriorAvoidanceTrigger | null, seed: string, now: number, ): ActiveAura[] { if (!trigger || !wow335WarriorImprovedDefensiveStanceShouldProc( rank, trigger, stableCombatUnit(`${seed}:wow335-warrior-improved-defensive-stance`), )) return [...auras]; return [...applyCombatAura(auras, { definition: wow335WarriorImprovedDefensiveStanceEnrageAuraDefinition(), sourceId: actorId, targetId: actorId, now, }).auras]; } function druidTalentRank(work: MutableCastWork, talentId: string, maxRank: number): number { return Math.min(maxRank, Math.max(0, Math.trunc(work.talentRanks[talentId] ?? 0))); } function maybeProcWarriorBloodCrazeInWork( work: MutableCastWork, wasCritical: boolean, now: number, ): void { if (work.classId !== "warrior" || !wasCritical || work.health <= 0 || (work.cooldowns[WOW335_WARRIOR_BLOOD_CRAZE_COOLDOWN_KEY] ?? 0) > now) return; const rank = Math.min(3, Math.max(0, Math.trunc(work.talentRanks[WOW335_WARRIOR_BLOOD_CRAZE_TALENT_ID] ?? 0))); const enraged = work.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now)); const profile = wow335WarriorBloodCrazeProfile( baseResourcesFor(work.classId, work.progression.level).health, rank, enraged, ); if (!profile) return; work.cooldowns[WOW335_WARRIOR_BLOOD_CRAZE_COOLDOWN_KEY] = now + WOW335_WARRIOR_BLOOD_CRAZE_INTERNAL_COOLDOWN_MS; work.effects.push(timedEffect( "hot", "wow335-warrior-blood-craze", PLAYER_AGGRO_ID, null, { coefficient: 0, fixedAmount: profile.healingPerTick }, profile.ticks, profile.intervalMs, now, "wow335-warrior-blood-craze", )); } function maybeProcWarriorSecondWindInWork( work: MutableCastWork, seed: string, now: number, ): void { if (work.classId !== "warrior" || work.health <= 0 || work.health / work.maxHealth >= 0.35 || (work.cooldowns[WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY] ?? 0) > now) return; const rank = Math.min(3, Math.max(0, Math.trunc(work.talentRanks[WOW335_WARRIOR_SECOND_WIND_TALENT_ID] ?? 0))); const profile = wow335WarriorSecondWindProfile( baseResourcesFor("warrior", work.progression.level).health, rank, ); if (!profile || stableCombatUnit(`${seed}:warrior-second-wind`) >= profile.procChancePercent / 100) return; work.cooldowns[WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY] = now + profile.cooldownMs; work.effects.push({ ...timedEffect( "hot", "wow335-warrior-second-wind", PLAYER_AGGRO_ID, null, { coefficient: 0, fixedAmount: profile.healingPerTick }, profile.ticks, profile.intervalMs, now, "wow335-warrior-second-wind", ), tickAmounts: profile.tickAmounts, }); } function maybeProcWarriorUnbridledWrathInWork( work: MutableCastWork, weaponSpeedMs: number, damagingLandedMainHandAutoAttack: boolean, seed: string, now: number, ): void { if (work.classId !== "warrior" || !damagingLandedMainHandAutoAttack) return; const rank = work.talentRanks[WOW335_WARRIOR_UNBRIDLED_WRATH_TALENT_ID] ?? 0; const profile = wow335WarriorUnbridledWrathProfile(rank); if (!profile || !wow335WarriorUnbridledWrathShouldProc( rank, weaponSpeedMs, stableCombatUnit(`${seed}:wow335-warrior-unbridled-wrath`), )) return; const before = work.resourcePools.rage ?? work.resource; const maximum = work.maxResourcePools.rage ?? work.maxResource; const after = Math.min(maximum, before + profile.ragePerProc); const effectiveAmount = after - before; work.resourcePools = { ...work.resourcePools, rage: after }; if (resourceProfileForClass(work.classId).type === "rage") work.resource = after; const playerActor = work.actors[PLAYER_AURA_ENTITY_ID]; if (playerActor) { const ragePool = playerActor.resources.rage ?? normalizeActorResourcePool("rage", before, maximum); work.actors[PLAYER_AURA_ENTITY_ID] = { ...playerActor, resources: { ...playerActor.resources, rage: { ...ragePool, current: after }, }, }; } recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: WOW335_WARRIOR_UNBRIDLED_WRATH_ABILITY_ID, rawAmount: profile.ragePerProc, effectiveAmount, overheal: profile.ragePerProc - effectiveAmount, absorbed: 0, critical: false, }); } function applyWarriorChargeTalentEffectsInWork( work: MutableCastWork, ability: AbilityDefinition, now: number, ): void { if (work.classId !== "warrior") return; const chargeRank = wow335WarriorChargeRank(ability.dbcSpellId); if (chargeRank <= 0) return; const angerRank = work.talentRanks[WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID] ?? 0; const profile = wow335WarriorChargeRageProfile( chargeRank, angerRank, ); if (!profile) return; const maximum = work.maxResourcePools.rage ?? work.maxResource; const before = work.resourcePools.rage ?? work.resource; const rage = Math.min(maximum, before + profile.totalRage); work.resourcePools = { ...work.resourcePools, rage }; if (resourceProfileForClass(work.classId).type === "rage") work.resource = rage; const angerManagement = wow335WarriorAngerManagementProfile(angerRank); if (!angerManagement || angerManagement.chargeDamageReductionPercent <= 0 || angerManagement.chargeDamageReductionDurationMs <= 0) return; const applied = applyCombatAura(work.auras, { definition: { 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, }], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }); work.auras = [...applied.auras]; } const PRIEST_SURGE_OF_LIGHT_AURA: AuraDefinition = { id: "wow335:priest:surge-of-light", name: "Surge of Light", description: "Your next Smite or Flash Heal is instant and costs no mana.", disposition: "buff", durationMs: 10_000, maxStacks: 1, stackBehavior: "refresh", utilityTags: ["wow335-priest-surge-of-light"], }; const PRIEST_SERENDIPITY_AURA: AuraDefinition = { id: "wow335:priest:serendipity", name: "Serendipity", description: "Each stack reduces the cast time and mana cost of your next Greater Heal or Prayer of Healing.", disposition: "buff", durationMs: 20_000, maxStacks: 3, stackBehavior: "add", utilityTags: ["wow335-priest-serendipity"], }; const PRIEST_SHADOW_WEAVING_AURA: AuraDefinition = { id: "wow335:priest:shadow-weaving", name: "Shadow Weaving", disposition: "buff", durationMs: 15_000, maxStacks: 5, stackBehavior: "add", utilityTags: ["wow335-priest-shadow-weaving"], modifiers: [{ kind: "damage", direction: "dealt", school: "shadow", operation: "percent", value: 0.02, perStack: true, }], }; const PRIEST_HOLY_CONCENTRATION_BASE: Omit = { id: "wow335:priest:holy-concentration", name: "Holy Concentration", disposition: "buff", durationMs: 12_000, maxStacks: 1, stackBehavior: "refresh", utilityTags: ["wow335-priest-holy-concentration"], }; const INNER_FOCUS_SERENDIPITY_BLOCKED_SPELLS = new Set([ "Holy Light", "Flash of Light", "Binding Heal", "Renew", "Greater Heal", "Flash Heal", "Smite", ]); function priestTalentRank(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank ), 0))); } function paladinTalentRank(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank ), 0))); } const PALADIN_VINDICATION_AURA_ID = "wow335:paladin:vindication"; const WOW335_WARLOCK_IMMOLATION_MOB_AURA_PREFIX = "wow335:warlock:immolation-ap:"; function applyWarlockImmolationAttackPowerInWork( work: MutableCastWork, targetId: string, sourceActorId: string, sourceAbilityId: string, sourceLevel: number, now: number, ): void { const mob = work.mobs[targetId]; if (!mob || mob.dead) return; const reduction = wow335WarlockImmolationAttackPowerReduction(sourceLevel); if (reduction <= 0) return; const id = `${WOW335_WARLOCK_IMMOLATION_MOB_AURA_PREFIX}${sourceActorId}`; work.mobs[targetId] = { ...mob, auras: [ ...mob.auras.filter((aura) => aura.id !== id), { id, sourceAbilityId, sourceActorId, endsAt: now + 30_000, magnitude: reduction, }, ], }; } function paladinVindicationSource( work: MutableCastWork, sourceActorId: AggroActorId, ): { classId: ClassId; talentRank: number; level: number } | null { if (sourceActorId === PLAYER_AGGRO_ID) { if (baseClassFor(work.classId) !== "paladin") return null; return { classId: work.classId, talentRank: Math.max(0, Math.trunc(work.talentRanks["talent-1633"] ?? 0)), level: work.progression.level, }; } const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); if (!member || baseClassFor(member.classId) !== "paladin") return null; return { classId: member.classId, talentRank: Math.max(0, Math.trunc(member.talentRanks["talent-1633"] ?? 0)), level: member.level, }; } function maybeProcPaladinVindicationInWork( work: MutableCastWork, targetId: string, source: ThreatSource, school: DamageSchool, now: number, landed: boolean, ): void { if (!landed || !source.abilityId) return; const paladin = paladinVindicationSource(work, source.actorId); if (!paladin) return; const ability = abilityById(source.abilityId); if (!ability || (school !== "holy" && abilityAttackKind(paladin.classId, ability) !== "melee")) return; const profile = wow335PaladinVindicationProfile(paladin.talentRank, paladin.level); if (!profile || stableCombatUnit( `${source.actorId}:${source.abilityId}:${targetId}:${now}:wow335-paladin-vindication`, ) >= profile.procChance) return; const mob = work.mobs[targetId]; if (!mob || mob.dead) return; work.mobs[targetId] = { ...mob, auras: [ ...mob.auras.filter((aura) => aura.id !== PALADIN_VINDICATION_AURA_ID), { id: PALADIN_VINDICATION_AURA_ID, sourceAbilityId: source.abilityId, sourceActorId: source.actorId, endsAt: now + profile.durationMs, magnitude: profile.attackPowerReduction, }, ], }; } const PALADIN_RECKONING_TAG = "wow335-paladin-reckoning"; const PALADIN_RECKONING_AURA: AuraDefinition = { id: "wow335:paladin:reckoning", name: "Reckoning", description: "Your next three auto attacks each generate an additional attack within 8 seconds.", disposition: "buff", durationMs: 8_000, maxStacks: 3, stackBehavior: "replace", utilityTags: [PALADIN_RECKONING_TAG], }; function maybeProcPaladinReckoningInWork( work: MutableCastWork, attackKind: WotlkAttackKind, wasHitOrAvoided: boolean, seed: string, now: number, ): void { if (work.classId !== "paladin" || attackKind !== "melee" || !wasHitOrAvoided) return; const rank = paladinTalentRank(work.talentRanks, "Reckoning", 5); const chance = [0, 0.03, 0.06, 0.09, 0.12, 0.15][rank] ?? 0; if (chance <= 0 || stableCombatUnit(`${seed}:wow335-paladin-reckoning`) >= chance) return; work.auras = [...applyCombatAura(work.auras, { definition: PALADIN_RECKONING_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, stacks: 3, }).auras]; } function maybeProcPaladinRedoubtInWork( work: MutableCastWork, blocked: boolean, hasShield: boolean, seed: string, now: number, ): void { if (work.classId !== "paladin" || !blocked || !hasShield) return; const profile = wow335PaladinRedoubtProfile(paladinTalentRankDescription(work.talentRanks, "Redoubt")); if (!profile || stableCombatUnit(`${seed}:wow335-paladin-redoubt`) >= profile.procChance) return; const baseStats = playerCharacterStats( work.classId, work.progression.level, work.gearStats, work.raceId, work.secondaryClassId, work.talentModifiers, work.gender, work.statRuleset, ); const strength = applyAuraStats(baseStats, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID)).strength; work.auras = [...applyCombatAura(work.auras, { definition: wow335PaladinRedoubtBuff(profile, strength), sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } function consumePaladinReckoningCharge(auras: readonly ActiveAura[], now: number): readonly ActiveAura[] { const index = auras.findIndex((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(PALADIN_RECKONING_TAG) && (aura.expiresAt === null || aura.expiresAt > now) && aura.stacks > 0); if (index < 0) return auras; const current = auras[index]; if (current.stacks <= 1) return auras.filter((aura) => aura.instanceId !== current.instanceId); return auras.map((aura, auraIndex) => auraIndex === index ? { ...aura, stacks: aura.stacks - 1 } : aura); } function maybeProcPaladinEyeForAnEyeInWork( work: MutableCastWork, attackerId: string, damageTaken: number, critical: boolean, now: number, ): void { if (work.classId !== "paladin" || !critical || damageTaken <= 0) return; const rank = paladinTalentRank(work.talentRanks, "Eye for an Eye", 3); const reflectPercent = [0, 5, 10, 15][rank] ?? 0; if (reflectPercent <= 0) return; const reflected = Math.min( Math.round(work.maxHealth * 0.5), Math.round(damageTaken * reflectPercent / 100), ); if (reflected <= 0) return; damageMobInWork( work.classId, work, attackerId, reflected, now, playerThreatSource({ talentModifiers: work.talentModifiers }, "talent-1632"), "holy", ); } function applyPriestSelfAuraInWork( work: MutableCastWork, definition: AuraDefinition, now: number, ): void { work.auras = [...applyCombatAura(work.auras, { definition, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } function maybeProcPriestDamageTalentsInWork( work: MutableCastWork, ability: AbilityDefinition | undefined, sourceActorId: string, school: DamageSchool, critical: boolean, landed: boolean, periodic: boolean, seed: string, now: number, ): void { if (work.classId !== "priest" || sourceActorId !== PLAYER_AGGRO_ID || !ability || !landed) return; const shadowWeavingRank = priestTalentRank(work.talentRanks, "Shadow Weaving", 3); const shadowWeavingChance = [0, 0.33, 0.66, 1][shadowWeavingRank] ?? 0; if (school === "shadow" && shadowWeavingRank > 0 && stableCombatUnit(`${seed}:wow335-priest-shadow-weaving`) < shadowWeavingChance) { applyPriestSelfAuraInWork(work, PRIEST_SHADOW_WEAVING_AURA, now); } if (periodic || !critical) return; const surgeRank = priestTalentRank(work.talentRanks, "Surge of Light", 2); const surgeChance = [0, 0.25, 0.5][surgeRank] ?? 0; if (surgeRank > 0 && stableCombatUnit(`${seed}:wow335-priest-surge-of-light`) < surgeChance) { applyPriestSelfAuraInWork(work, PRIEST_SURGE_OF_LIGHT_AURA, now); } if (["Smite", "Holy Fire"].includes(ability.name)) { const holyConcentrationRank = priestTalentRank(work.talentRanks, "Holy Concentration", 3); if (holyConcentrationRank > 0) { applyPriestSelfAuraInWork(work, { ...PRIEST_HOLY_CONCENTRATION_BASE, modifiers: [{ kind: "stat", stat: "spirit", operation: "percent", value: ([0, 0.1, 0.2, 0.3][holyConcentrationRank] ?? 0), }], }, now); } } } function maybeExtendPriestRenewInWork( work: MutableCastWork, rank: number, playerPosition: CombatPosition, seed: string, now: number, ): void { const chance = [0, 0.33, 0.66, 1][Math.min(3, Math.max(0, rank))] ?? 0; if (work.classId !== "priest" || chance <= 0 || stableCombatUnit(`${seed}:wow335-priest-holy-concentration-renew`) >= chance) return; const eligibleTargets = new Set([PLAYER_AURA_ENTITY_ID]); const partyMembers = usePartyStore.getState().members; const nearby = (id: string): boolean => { const position = id === PLAYER_AURA_ENTITY_ID ? playerPosition : getPartyRuntimePosition(id); return Boolean(position && Math.sqrt(distanceSquared(playerPosition, position)) <= 40 && combatHasLineOfSight(playerPosition, position)); }; for (const member of partyMembers) { if (member.health > 0 && nearby(member.id)) eligibleTargets.add(member.id); } for (const actor of onlinePlayerRuntimeActors()) { if (actor.health > 0 && nearby(actor.id)) eligibleTargets.add(actor.id); } work.effects = work.effects.map((effect) => { if (effect.kind !== "hot" || effect.sourceActorId !== PLAYER_AGGRO_ID || abilityById(effect.sourceAbilityId)?.name !== "Renew" || !eligibleTargets.has(effect.targetId ?? PLAYER_AURA_ENTITY_ID) || (effect.extensionTicks ?? 0) >= 2 || effect.remainingTicks <= 0) return effect; const expiresAt = effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs; if (expiresAt < now) return effect; return { ...effect, remainingTicks: effect.remainingTicks + 1, extensionTicks: (effect.extensionTicks ?? 0) + 1 }; }); } function applyDruidSelfAura(work: MutableCastWork, definition: AuraDefinition, now: number): void { work.auras = [...applyCombatAura(work.auras, { definition, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } function maybeProcDruidLivingSeed( work: MutableCastWork, ability: AbilityDefinition, targetId: string, critical: boolean, effectiveHealing: number, seed: string, now: number, ): void { if (work.classId !== "druid" || ability.source === "runewaker" || !DRUID_LIVING_SEED_ELIGIBLE_HEALS.has(ability.name) || !critical || effectiveHealing <= 0) return; const rank = druidTalentRank(work, "talent-1922", 3); const procChance = [0, 0.33, 0.66, 1][rank] ?? 0; if (rank <= 0 || stableCombatUnit(seed) >= procChance) return; const applied = applyCombatAura(work.auras, { definition: DRUID_LIVING_SEED_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId, now, }); work.auras = [...applied.auras.map((aura) => aura.instanceId === applied.aura.instanceId ? { ...aura, druidLivingSeedAmount: Math.round(effectiveHealing * 0.3) } : aura)]; } function consumeDruidLivingSeed( auras: readonly ActiveAura[], targetId: string, now: number, ): { auras: ActiveAura[]; healingAmount: number } { const seedAura = auras.find((aura) => aura.targetId === targetId && aura.definition.utilityTags?.includes("wow335-druid-living-seed")); if (!seedAura) return { auras: [...auras], healingAmount: 0 }; return { auras: auras.filter((aura) => aura.instanceId !== seedAura.instanceId), healingAmount: seedAura.expiresAt === null || seedAura.expiresAt > now ? Math.max(0, Math.round(seedAura.druidLivingSeedAmount ?? 0)) : 0, }; } function maybeProcDruidNatureGrace( work: MutableCastWork, ability: AbilityDefinition, critical: boolean, seed: string, now: number, ): void { if (work.classId !== "druid" || !critical || ability.source === "runewaker") return; const rank = druidTalentRank(work, "talent-789", 3); if (rank <= 0 || stableCombatUnit(seed) >= rank / 3) return; applyDruidSelfAura(work, DRUID_NATURES_GRACE_AURA, now); } function maybeProcDruidOmenOfClarity(work: MutableCastWork, seed: string, now: number): void { if (work.classId !== "druid") return; const rank = druidTalentRank(work, "talent-827", 3); const cooldownKey = "wow335-druid-omen-of-clarity"; if (rank <= 0 || (work.cooldowns[cooldownKey] ?? 0) > now || stableCombatUnit(seed) >= rank * 0.02) return; work.cooldowns[cooldownKey] = now + 2_000; applyDruidSelfAura(work, DRUID_CLEARCASTING_AURA, now); } function maybeProcDruidPrimalFury( work: MutableCastWork, sourceActorId: string, periodic: boolean, critical: boolean, landed: boolean, seed: string, now: number, ): void { if (work.classId !== "druid" || sourceActorId !== PLAYER_AGGRO_ID || periodic || !critical || !landed) return; const rank = druidTalentRank(work, "talent-801", 2); if (rank <= 0) return; const activeFormSpellIds = new Set(work.auras .filter((aura) => aura.targetId === sourceActorId && (aura.expiresAt === null || aura.expiresAt > now)) .flatMap((aura) => { const match = /^wow335:(5487|9634|768):/.exec(aura.definition.id); return match ? [Number(match[1])] : []; })); const procChance = rank / 2; if ((activeFormSpellIds.has(5487) || activeFormSpellIds.has(9634)) && stableCombatUnit(`${seed}:wow335-druid-primal-fury-rage`) < procChance) { const maximum = work.maxResourcePools.rage ?? 100; const before = work.resourcePools.rage ?? 0; const after = clamp(before + 5, 0, maximum); work.resourcePools = { ...work.resourcePools, rage: after }; if (resourceProfileForClass(work.classId).type === "rage") work.resource = after; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "wow335-druid-primal-fury", rawAmount: 5, effectiveAmount: after - before, overheal: 5 - (after - before), absorbed: 0, critical: false, }); } } function maybeProcDruidPrimalFuryCombo( work: MutableCastWork, ability: AbilityDefinition, targetId: string, comboAmount: number, combatEventIdBeforeAbilityEffects: number, now: number, ): void { if (work.classId !== "druid" || ability.source === "runewaker" || comboAmount <= 0 || !ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "finisher-damage")) return; const rank = druidTalentRank(work, "talent-801", 2); const catFormActive = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.id.startsWith("wow335:768:")); const directCritical = work.combatEvents.some((event) => event.id > combatEventIdBeforeAbilityEffects && event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID && event.targetActorId === targetId && event.abilityId === ability.id && event.occurredAt === now && event.critical); const procChance = rank / 2; if (rank <= 0 || !catFormActive || !directCritical || work.comboPoints >= 5 || stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${targetId}:${now}:wow335-druid-primal-fury-combo`) >= procChance) return; work.comboPoints = clamp((work.comboTargetId === targetId ? work.comboPoints : 0) + 1, 0, 5); work.comboTargetId = targetId; } function maybeProcDruidEclipse(work: MutableCastWork, ability: AbilityDefinition, seed: string, now: number): void { if (work.classId !== "druid" || (ability.name !== "Wrath" && ability.name !== "Starfire")) return; const rank = druidTalentRank(work, "talent-1924", 3); const solar = ability.name === "Starfire"; const auraTag = solar ? "wow335-druid-eclipse-solar" : "wow335-druid-eclipse-lunar"; const otherAuraTag = solar ? "wow335-druid-eclipse-lunar" : "wow335-druid-eclipse-solar"; const cooldownKey = solar ? "wow335-druid-eclipse-solar" : "wow335-druid-eclipse-lunar"; const eclipseActive = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && (aura.definition.utilityTags?.includes("wow335-druid-eclipse-solar") || aura.definition.utilityTags?.includes("wow335-druid-eclipse-lunar")) && (aura.expiresAt === null || aura.expiresAt > now)); const procChance = rank / 3 * (solar ? 1 : 0.6); if (rank <= 0 || eclipseActive || (work.cooldowns[cooldownKey] ?? 0) > now || stableCombatUnit(seed) >= procChance) return; const definition: AuraDefinition = { id: `wow335:druid:eclipse:${solar ? "solar" : "lunar"}`, name: solar ? "Solar Eclipse" : "Lunar Eclipse", disposition: "buff", durationMs: 15_000, maxStacks: 1, stackBehavior: "refresh", utilityTags: [auraTag], }; work.cooldowns[cooldownKey] = now + 30_000; work.auras = work.auras.filter((aura) => !aura.definition.utilityTags?.includes(otherAuraTag)); applyDruidSelfAura(work, definition, now); } interface SecondaryDamagePacket { readonly amount: number; readonly school: DamageSchool; readonly spellPenetration: number; readonly attackKind?: WotlkAttackKind; } function damageMobInWork( classId: ClassId, work: MutableCastWork, id: string, amount: number, now: number, source: ThreatSource, school: DamageSchool = "physical", spellPenetration = 0, combatResult?: CombatResult, secondaryDamage?: SecondaryDamagePacket, primarySaintsBlessingMultiplierAlreadyApplied = false, directPhysicalArmorAttackerLevel?: number, attackHand?: import("./combatEvents").StructuredCombatEvent["attackHand"], ): DamageResult { const mob = work.mobs[id]; if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) { return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 }; } const saintsBlessingMultiplier = romPriestScoutSaintsBlessingMultiplierForHit( work, classId, source.actorId, mob, ); const primaryMultiplier = primarySaintsBlessingMultiplierAlreadyApplied ? 1 : saintsBlessingMultiplier; const resolvedPrimaryAmount = amount * primaryMultiplier; const resolvedSecondaryAmount = secondaryDamage ? secondaryDamage.amount * saintsBlessingMultiplier : 0; const resistedBeforeArmor = combatResult ? combatResult.finalDamage * primaryMultiplier : damageAfterMobResistance( resolvedPrimaryAmount, mob, school, spellPenetration, work.progression.level, ); const mitigatedBeforeAuras = combatResult ? resistedBeforeArmor : school === "physical" && directPhysicalArmorAttackerLevel !== undefined ? damageAfterArmor(resistedBeforeArmor, mob.armor, directPhysicalArmorAttackerLevel) : resistedBeforeArmor; const targetDamageAuras = aurasForEntity(work.auras, id).filter((aura) => ( !aura.definition.id.startsWith("enemy-source-spell-") || aura.expiresAt === null || aura.expiresAt > now )); const mitigated = resolveAuraDamageValue( mitigatedBeforeAuras, targetDamageAuras, { direction: "taken", school, attackKind: "spell" }, ); const auraAbsorb = absorbAuraDamage(work.auras, { targetId: id, amount: mitigated, school, now, }); work.auras = [...auraAbsorb.auras]; const secondaryMitigatedBeforeAuras = secondaryDamage ? damageAfterMobResistance( resolvedSecondaryAmount, mob, secondaryDamage.school, secondaryDamage.spellPenetration, work.progression.level, ) : 0; const secondaryMitigated = secondaryDamage ? resolveAuraDamageValue( secondaryMitigatedBeforeAuras, targetDamageAuras, { direction: "taken", school: secondaryDamage.school, attackKind: secondaryDamage.attackKind ?? "spell", }, ) : 0; const secondaryAuraAbsorb = secondaryDamage ? absorbAuraDamage(work.auras, { targetId: id, amount: secondaryMitigated, school: secondaryDamage.school, now, }) : null; if (secondaryAuraAbsorb) work.auras = [...secondaryAuraAbsorb.auras]; const characterShield = mob.characterShield ?? 0; const primaryCharacterAbsorbed = Math.min(characterShield, auraAbsorb.remainingDamage); const primaryRemainingDamage = Math.max(0, auraAbsorb.remainingDamage - primaryCharacterAbsorbed); const secondaryCharacterAbsorbed = secondaryAuraAbsorb ? Math.min(Math.max(0, characterShield - primaryCharacterAbsorbed), secondaryAuraAbsorb.remainingDamage) : 0; const secondaryRemainingDamage = secondaryAuraAbsorb ? Math.max(0, secondaryAuraAbsorb.remainingDamage - secondaryCharacterAbsorbed) : 0; const characterAbsorbed = primaryCharacterAbsorbed + secondaryCharacterAbsorbed; const remainingDamage = primaryRemainingDamage + secondaryRemainingDamage; const adjustedCombatResult = combatResult ? { ...combatResult, finalDamage: Math.max(0, Math.round(primaryRemainingDamage)) } : undefined; if (adjustedCombatResult) work.combatResults.push(adjustedCombatResult); const applied = Math.min(mob.health, Math.max(0, Math.round(remainingDamage))); const primaryApplied = secondaryDamage ? Math.min(applied, Math.max(0, Math.round(primaryRemainingDamage))) : applied; recordCombatEventInWork(work, { occurredAt: now, kind: "damage", sourceActorId: source.actorId, targetActorId: id, ...(source.abilityId ? { abilityId: source.abilityId } : {}), school, ...(attackHand ? { attackHand } : {}), rawAmount: combatResult?.rawDamage !== undefined ? Math.max(0, Math.round(combatResult.rawDamage * primaryMultiplier)) : Math.max(0, Math.round(resolvedPrimaryAmount)), effectiveAmount: primaryApplied, overheal: 0, absorbed: auraAbsorb.absorbed + primaryCharacterAbsorbed, critical: combatResult?.critical ?? false, outcome: combatResult?.outcome ?? "hit", ...(combatResult ? { blocked: combatResult.blocked, blockedAmount: combatResult.blockedAmount, ...(combatResult.criticalBlocked === undefined ? {} : { criticalBlocked: combatResult.criticalBlocked }) } : {}), }); if (secondaryDamage && secondaryAuraAbsorb) { recordCombatEventInWork(work, { occurredAt: now, kind: "damage", sourceActorId: source.actorId, targetActorId: id, ...(source.abilityId ? { abilityId: source.abilityId } : {}), school: secondaryDamage.school, rawAmount: Math.max(0, Math.round(resolvedSecondaryAmount)), effectiveAmount: Math.max(0, applied - primaryApplied), overheal: 0, absorbed: secondaryAuraAbsorb.absorbed + secondaryCharacterAbsorbed, critical: false, outcome: "hit", }); } maybeProcPaladinVindicationInWork( work, id, source, school, now, combatResult?.landed ?? applied > 0, ); const thorns = mob.auras.find((aura) => aura.id === "thorns"); if (thorns && source.actorId === PLAYER_AGGRO_ID && applied > 0) { const reflected = Math.min( work.health, Math.max(1, Math.round(applied * (thorns.magnitude ?? 0.08))), ); work.health -= reflected; work.progression.health = work.health; } const health = mob.health - applied; const killed = health <= 0; const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt; const testing = useGameStore.getState().gmTestMode; const canAward = killed && !mob.experienceGranted && !testing; const beforeLevel = work.progression.level; const ownerCategory = useCombatStore.getState().contentCategoryId; const hasTreasureHunter = work.auras.some((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(ROM_ROGUE_TREASURE_HUNTER_TAG) )); const pendingLoot = testing ? [] : killed && mob.lootSourceId ? rollBossLoot(mob.lootSourceId, beforeLevel, { now, ownerCategory, bonusItemChance: romRogueTreasureHunterLootChance(mob.bonusLootChance, hasTreasureHunter), }) : mob.pendingLoot.filter((item) => inventoryItemUsableForCategory(item, ownerCategory)); const card = killed && mob.boss && !testing ? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name) : null; const bossCardDrop = card ? { bossKey: card.key, spawnId: mob.id, claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`, rarities: rollBossCards(), } satisfies BossCardDrop : mob.bossCardDrop; const hasLoot = testing ? false : killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot; const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0; if (classId === "death-knight" && source.actorId === PLAYER_AGGRO_ID && canAward && mob.xpReward > 0) { const butcheryRank = Math.min(2, Math.max(0, Object.entries(work.talentRanks).reduce((rank, [nodeId, points]) => ( talentNodeById(nodeId)?.name === "Butchery" ? Math.max(rank, points) : rank ), 0))); const runicPowerGain = [0, 10, 20][butcheryRank] ?? 0; const runicPower = work.resourcePools["runic-power"] ?? work.resource; const maximumRunicPower = work.maxResourcePools["runic-power"] ?? work.maxResource; if (runicPowerGain > 0) { work.resource = clamp(runicPower + runicPowerGain, 0, maximumRunicPower); work.resourcePools["runic-power"] = work.resource; } } engageMobGroupInWork(work, id, now, source.actorId); const engagedMob = work.mobs[id] ?? mob; const threatByActor = killed ? {} : addActorThreat( engagedMob.threatByActor, activeTricksThreatRecipient(work.auras, source.actorId, now) ?? source.actorId, threatFromDamage(applied, source.role, source.threatScale) * resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat"), ); work.mobs[id] = { ...engagedMob, ...(killed ? resetEnemyWeaponAttackState(engagedMob) : {}), ...(mob.characterShield !== undefined ? { characterShield: mob.characterShield - characterAbsorbed } : {}), health, dead: killed, hasLoot, pendingLoot, bossCardDrop, looted: killed ? false : mob.looted, despawned: false, diedAt: killed ? now : mob.diedAt, corpseDespawnAt: killed ? (hasLoot ? null : now + EMPTY_CORPSE_DESPAWN_MS) : mob.corpseDespawnAt, experienceGranted: mob.experienceGranted || killed, lastDamageAt: now, statuses: killed ? [] : applied > 0 ? mob.statuses.filter((status) => !(status.breakOnDamage ?? ["fear", "sleep", "blind", "confuse"].includes(status.kind))) : mob.statuses, threatByActor, targetActorId: killed ? null : (engagedMob.targetActorId ?? source.actorId), forcedTarget: killed ? null : engagedMob.forcedTarget, engaged: !killed, combatPhase: killed ? "idle" : "chasing", homePosition: engagedMob.homePosition ?? getMobPosition(id), nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450), activeCast: killed ? null : mob.characterDefender && applied > 0 && engagedMob.activeCast ? { ...engagedMob.activeCast, ...applyWrathCastPushback(engagedMob.activeCast as unknown as ActiveCast, now, `${source.actorId}:${now}`) } : engagedMob.activeCast, woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision, nextWoundAnimationAt: canPlayWoundAnimation ? now + MOB_WOUND_ANIMATION_COOLDOWN_MS : mob.nextWoundAnimationAt, deathRevision: killed ? mob.deathRevision + 1 : mob.deathRevision, }; const seedsToDetonate: TimedCombatEffect[] = []; if (classId === "warlock" && (applied > 0 || killed)) { work.effects = work.effects.flatMap((effect) => { if (effect.kind !== "dot" || effect.targetId !== id || !effect.warlockSeed) return [effect]; const accumulatedDamage = effect.warlockSeed.accumulatedDamage + applied; if (killed || accumulatedDamage >= effect.warlockSeed.threshold) { seedsToDetonate.push(effect); return []; } return [{ ...effect, warlockSeed: { ...effect.warlockSeed, accumulatedDamage } }]; }); } // Pulling a member links the whole authored pack even when the opening hit // kills that member outright. The dead source remains inert because the // group helper deliberately skips dead mobs. work.totalDamage += applied; if (classId === "warlock" && source.actorId === PLAYER_AGGRO_ID && applied > 0) { const sourceAbility = source.abilityId ? abilityById(source.abilityId) : null; const felSynergyRank = warlockTalentRank(work.talentRanks, "Fel Synergy", 2); const pet = work.summons.find((summon) => summon.kind === "pet" && summon.ownerActorId === PLAYER_AGGRO_ID && summon.health > 0 && summon.expiresAt > now); if (sourceAbility && abilityAttackKind(classId, sourceAbility) === "spell" && felSynergyRank > 0 && pet && stableCombatUnit(`${sourceAbility.id}:${id}:${now}:wow335-warlock-fel-synergy`) < felSynergyRank / 2) { const requested = Math.max(0, Math.round(applied * 0.15)); const healed = Math.min(requested, pet.maxHealth - pet.health); if (healed > 0) { work.summons = work.summons.map((summon) => summon.id === pet.id ? { ...summon, health: summon.health + healed } : summon); work.totalHealing += healed; recordCombatEventInWork(work, { occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID, targetActorId: pet.id, abilityId: sourceAbility.id, school, rawAmount: requested, effectiveAmount: healed, overheal: Math.max(0, requested - healed), absorbed: 0, critical: false, }); } } } for (const seed of seedsToDetonate) detonateWarlockSeedInWork(work, seed, now); if (applied > 0) { evaluateProcsInWork(work, { trigger: "damage-taken", subjectId: id, otherId: source.actorId, abilityId: source.abilityId, amount: applied, school, }, now); } return { amount: applied, killed, xpAwarded, levelsGained: work.progression.level - beforeLevel, ...(adjustedCombatResult ? { combatResult: adjustedCombatResult } : {}), }; } function detonateWarlockSeedInWork( work: MutableCastWork, seed: TimedCombatEffect, now: number, ): void { const profile = seed.warlockSeed; if (seed.kind !== "dot" || !seed.targetId || !profile) return; const center = getMobPosition(seed.targetId); const ability = triggeredAbilityBySpellId(profile.explosionSpellId, false); const damageEffect = ability?.effects.find((effect) => effect.kind === "damage"); if (!center || !ability || damageEffect?.kind !== "damage") return; const sourceMember = usePartyStore.getState().members.find((member) => member.id === seed.sourceActorId); const sourceLevel = sourceMember?.level ?? seed.sourceLevel ?? work.progression.level; const sourceClassId = sourceMember?.classId ?? work.classId; const sourceGearStats = sourceMember?.gearStats ?? work.gearStats; const sourceRaceId = sourceMember?.raceId ?? work.raceId; const sourceGender = sourceMember?.gender ?? work.gender; const sourceStatRuleset = sourceMember?.statRuleset ?? work.statRuleset; const amount = talentAdjustedAmount( equipmentAdjustedCombatAmount(damageEffect, sourceLevel, sourceGearStats, sourceClassId, ability, 0, undefined, sourceRaceId, sourceGender, sourceStatRuleset, sourceMember?.secondaryClassId ?? work.secondaryClassId), work.talentModifiers, "damage-percent", ability, ); const source = threatSourceForActor( { talentModifiers: work.talentModifiers }, seed.sourceActorId, seed.sourceAbilityId, ); for (const id of nearbyMobRuntimeIds(center, profile.radius)) { if (id === seed.targetId || work.mobs[id]?.dead) continue; const position = getMobPosition(id); if (!position || Math.sqrt(distanceSquared(center, position)) > profile.radius || !combatHasLineOfSight(center, position)) continue; resolveAndDamageMobInWork( work.classId, work, id, amount, now, source, "spell", "shadow", `${seed.id}:${id}:${now}:seed-of-corruption`, ability, false, { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, ); } const sourceAbility = abilityById(seed.sourceAbilityId); if (!sourceAbility) return; emitCombatPresentation({ occurredAt: now, phase: "impact", actorGroup: seed.sourceActorId === PLAYER_AGGRO_ID ? "player" : "party", sourceActorId: seed.sourceActorId, targetActorId: seed.targetId, abilityId: sourceAbility.id, sourceSpellId: ability.dbcSpellId, name: sourceAbility.name, source: presentationSourceForAbility(sourceAbility), school: "shadow", delivery: "area", origin: center, targetPosition: center, }); } const MAGE_HOT_STREAK_CRITICAL_SPELLS = new Set([ "Fireball", "Fire Blast", "Scorch", "Living Bomb", "Frostfire Bolt", ]); function mageTalentRank(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank ), 0))); } interface Wow335MageTalentOwner { readonly level: number; readonly talentRanks: TalentRanks; readonly spellPenetration: number; } function wow335MageTalentOwnerForActor( work: Pick, sourceActorId: AggroActorId, ): Wow335MageTalentOwner | null { if (sourceActorId === PLAYER_AGGRO_ID) { return work.classId === "mage" ? { level: work.progression.level, talentRanks: work.talentRanks, spellPenetration: work.gearStats.spellPenetration, } : null; } const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); return member?.classId === "mage" ? { level: member.level, talentRanks: member.talentRanks, spellPenetration: member.gearStats.spellPenetration } : null; } function applyMageFrostbiteInWork( work: MutableCastWork, sourceActorId: AggroActorId, targetId: string, abilityId: string, rank: number, school: string, landed: boolean, damage: number, roll: number, now: number, ): boolean { const durationMs = wow335MageFrostbiteProcDurationMs(rank, school, landed, damage, roll); const target = work.mobs[targetId]; if (durationMs <= 0 || !target) return false; work.mobs[targetId] = applyStatus(target, { kind: "status", status: "freeze", durationMs, }, abilityId, now, sourceActorId); return true; } function maybeProcMageMissileBarrage( work: MutableCastWork, abilityName: string, seed: string, now: number, ): boolean { if (work.classId !== "mage") return false; const rank = mageTalentRank(work.talentRanks, "Missile Barrage", 3); const chance = wow335MageMissileBarrageReachableProcChancePercentForSpell(rank, abilityName) / 100; if (chance <= 0 || stableCombatUnit(`${seed}:wow335-mage-missile-barrage`) >= chance) return false; work.auras = [...applyCombatAura(work.auras, { definition: WOW335_MAGE_MISSILE_BARRAGE_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; return true; } const WARLOCK_SOUL_LEECH_DIRECT_SPELLS = new Set([ "Chaos Bolt", "Conflagrate", "Immolate", "Incinerate", "Searing Pain", "Shadow Bolt", "Shadowburn", "Soul Fire", ]); const WARLOCK_ERADICATION_TAG = "wow335-warlock-eradication"; const WARLOCK_IMPROVED_SHADOW_BOLT_TAG = "wow335-warlock-improved-shadow-bolt"; const WARLOCK_SOUL_LEECH_COOLDOWN_TAG = "wow335-warlock-soul-leech-cooldown"; const WARLOCK_BACKLASH_READY_TAG = "wow335-warlock-backlash-ready"; const WARLOCK_BACKLASH_COOLDOWN_TAG = "wow335-warlock-backlash-cooldown"; const WARLOCK_BACKLASH_SPELLS = new Set(["Shadow Bolt", "Incinerate"]); const WARLOCK_BACKDRAFT_READY_AURA: AuraDefinition = { 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: 15_000, maxStacks: 3, initialStacks: 3, stackBehavior: "replace", utilityTags: [WARLOCK_BACKDRAFT_READY_TAG], }; function consumeWarlockBackdraftCharge(auras: readonly ActiveAura[], instanceId: string): ActiveAura[] { return auras.flatMap((aura) => { if (aura.instanceId !== instanceId) return [aura]; return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; }); } function maybeApplyWarlockBackdraft( auras: readonly ActiveAura[], classId: ClassId, talentRanks: TalentRanks, abilityName: string, now: number, ): ActiveAura[] { if (classId !== "warlock" || !warlockBackdraftTriggerEligible(abilityName)) return [...auras]; const effects = warlockBackdraftEffectsForRank(warlockTalentRank(talentRanks, "Backdraft", 3)); if (effects.rank <= 0) return [...auras]; return [...applyCombatAura(auras, { definition: WARLOCK_BACKDRAFT_READY_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, stacks: effects.charges, }).auras]; } const WARLOCK_NIGHTFALL_READY_AURA: AuraDefinition = { id: "wow335:warlock:nightfall-ready", name: "Nightfall", description: "Your next Shadow Bolt or Seed of Corruption is instant and costs less mana.", disposition: "buff", // The generated talent rank rows do not supply the proc-aura duration. // WotLKDB's linked Shadow Trance spell 17941 supplies 10 seconds. durationMs: 10_000, maxStacks: 1, stackBehavior: "refresh", utilityTags: [WARLOCK_NIGHTFALL_READY_TAG], }; const WARLOCK_NIGHTFALL_COOLDOWN_AURA: AuraDefinition = { id: "wow335:warlock:nightfall-cooldown", name: "Nightfall Internal Cooldown", disposition: "buff", durationMs: 5_000, hideFromAuraStrip: true, utilityTags: [WARLOCK_NIGHTFALL_COOLDOWN_TAG], }; function warlockTalentRank(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank ), 0))); } const WARLOCK_BACKLASH_READY_AURA: AuraDefinition = { id: "wow335:warlock:backlash-ready", name: "Backlash", description: "Your next Shadow Bolt or Incinerate is instant, costs less mana, and deals increased damage.", disposition: "buff", durationMs: 12_000, maxStacks: 1, stackBehavior: "refresh", utilityTags: [WARLOCK_BACKLASH_READY_TAG], }; const WARLOCK_BACKLASH_COOLDOWN_AURA: AuraDefinition = { id: "wow335:warlock:backlash-cooldown", name: "Backlash Internal Cooldown", disposition: "buff", durationMs: 8_000, hideFromAuraStrip: true, utilityTags: [WARLOCK_BACKLASH_COOLDOWN_TAG], }; function maybeApplyWarlockBacklash( auras: readonly ActiveAura[], classId: ClassId, talentRanks: TalentRanks, attackKind: WotlkAttackKind, school: DamageSchool, landed: boolean, seed: string, now: number, ): ActiveAura[] { if (classId !== "warlock" || school !== "physical" || attackKind === "spell" || !landed) return [...auras]; const rank = warlockTalentRank(talentRanks, "Backlash", 3); if (rank <= 0) return [...auras]; const coolingDown = auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(WARLOCK_BACKLASH_COOLDOWN_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); const chance = [0, 0.08, 0.16, 0.25][rank] ?? 0; if (coolingDown || stableCombatUnit(`${seed}:wow335-warlock-backlash`) >= chance) return [...auras]; const readyAuras = applyCombatAura(auras, { definition: WARLOCK_BACKLASH_READY_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras; return [...applyCombatAura(readyAuras, { definition: WARLOCK_BACKLASH_COOLDOWN_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } const WARLOCK_SOUL_LEECH_COOLDOWN_AURA: AuraDefinition = { id: "wow335:warlock:soul-leech-cooldown", name: "Soul Leech Cooldown", disposition: "buff", durationMs: 3_000, hideFromAuraStrip: true, utilityTags: [WARLOCK_SOUL_LEECH_COOLDOWN_TAG], }; function maybeProcWarlockDamageTalentsInWork( work: MutableCastWork, ability: AbilityDefinition | undefined, sourceActorId: string, targetId: string, result: DamageResult, attackKind: WotlkAttackKind, periodic: boolean, seed: string, now: number, ): void { if (work.classId !== "warlock" || sourceActorId !== PLAYER_AGGRO_ID || !ability || attackKind !== "spell" || !result.combatResult?.landed || result.amount <= 0) return; if (warlockNightfallTriggerEligible(ability.name)) { const nightfall = warlockNightfallEffectsForRank(warlockTalentRank(work.talentRanks, "Nightfall", 2)); const coolingDown = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(WARLOCK_NIGHTFALL_COOLDOWN_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); if (nightfall.rank > 0 && !coolingDown && stableCombatUnit(`${seed}:wow335-warlock-nightfall`) < nightfall.procChancePercent / 100) { work.auras = [...applyCombatAura(work.auras, { definition: WARLOCK_NIGHTFALL_READY_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; work.auras = [...applyCombatAura(work.auras, { definition: WARLOCK_NIGHTFALL_COOLDOWN_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } if (ability.name === "Corruption") { const eradicationRank = warlockTalentRank(work.talentRanks, "Eradication", 3); if (eradicationRank > 0 && stableCombatUnit(`${seed}:wow335-warlock-eradication`) < 0.15) { work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:warlock:eradication", name: "Eradication", disposition: "buff", durationMs: 10_000, stackBehavior: "refresh", utilityTags: [WARLOCK_ERADICATION_TAG], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } if (!periodic && ability.name === "Shadow Bolt" && result.combatResult.critical) { const improvedShadowBoltRank = warlockTalentRank(work.talentRanks, "Improved Shadow Bolt", 5); if (improvedShadowBoltRank > 0 && stableCombatUnit(`${seed}:wow335-warlock-improved-shadow-bolt`) < improvedShadowBoltRank * 0.2) { work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:warlock:improved-shadow-bolt-vulnerability", name: "Improved Shadow Bolt", disposition: "debuff", durationMs: 30_000, stackBehavior: "replace", utilityTags: [WARLOCK_IMPROVED_SHADOW_BOLT_TAG], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId, now, }).auras]; } } if (periodic || !WARLOCK_SOUL_LEECH_DIRECT_SPELLS.has(ability.name)) return; const soulLeechRank = warlockTalentRank(work.talentRanks, "Soul Leech", 3); const soulLeechCoolingDown = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(WARLOCK_SOUL_LEECH_COOLDOWN_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); const soulLeechChance = [0, 0.33, 0.66, 1][soulLeechRank] ?? 0; if (soulLeechRank <= 0 || soulLeechCoolingDown || stableCombatUnit(`${seed}:wow335-warlock-soul-leech`) >= soulLeechChance) return; const requestedHealing = Math.min( Math.round(result.amount * 0.15), Math.round(work.maxHealth * 0.15), ); const healed = healPlayerInWork(work, requestedHealing); work.totalHealing += healed; recordCombatEventInWork(work, { occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, rawAmount: requestedHealing, effectiveAmount: healed, overheal: Math.max(0, requestedHealing - healed), absorbed: 0, critical: false, }); const target = work.mobs[targetId]; if (target && !target.dead) { work.mobs[targetId] = { ...target, auras: [ ...target.auras.filter((aura) => aura.id !== WARLOCK_SOUL_LEECH_AP_DEBUFF_AURA_ID), { id: WARLOCK_SOUL_LEECH_AP_DEBUFF_AURA_ID, sourceAbilityId: ability.id, sourceActorId: PLAYER_AGGRO_ID, endsAt: now + WARLOCK_SOUL_LEECH_AP_DEBUFF_DURATION_MS, magnitude: WARLOCK_SOUL_LEECH_AP_DEBUFF_AMOUNT, }, ], }; } work.auras = [...applyCombatAura(work.auras, { definition: WARLOCK_SOUL_LEECH_COOLDOWN_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } function shamanTalentRankFromRanks(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank ), 0))); } function shamanTalentRank(work: MutableCastWork, talentName: string, maximumRank: number): number { return work.classId === "shaman" ? shamanTalentRankFromRanks(work.talentRanks, talentName, maximumRank) : 0; } function shamanTalentRanksForActor(work: MutableCastWork, sourceActorId: string): TalentRanks | null { if (sourceActorId === PLAYER_AGGRO_ID) return work.classId === "shaman" ? work.talentRanks : null; const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); return member?.classId === "shaman" ? member.talentRanks : null; } function maybeProcShamanFlurryInWork( work: MutableCastWork, sourceActorId: string, attackKind: WotlkAttackKind, landed: boolean, critical: boolean, periodic: boolean, rules: CombatResultRules, now: number, ): void { const rank = shamanTalentRanksForActor(work, sourceActorId)?.[WOW335_SHAMAN_FLURRY_TALENT_ID] ?? 0; if (rules.isAutoAttack && attackKind === "melee" && !periodic) { work.auras = [...wow335ShamanFlurryConsumeSwingCharge(work.auras, sourceActorId, now)]; } if (!periodic && wow335ShamanFlurryShouldProc(rank, { attackKind, landed, critical })) { work.auras = [...wow335ShamanFlurryApplyProc(work.auras, sourceActorId, rank, now)]; } } function shamanTalentRankForActor( work: MutableCastWork, sourceActorId: string, talentName: string, maximumRank: number, ): number { const talentRanks = shamanTalentRanksForActor(work, sourceActorId); return talentRanks ? shamanTalentRankFromRanks(talentRanks, talentName, maximumRank) : 0; } function maybeResolveShamanLightningOverloadInWork( work: MutableCastWork, targetId: string, initiatingSpellAmount: number, now: number, source: ThreatSource, ability: AbilityDefinition, school: DamageSchool, landed: boolean, critical: boolean, periodic: boolean, seed: string, ): void { if (work.classId !== "shaman" || ability.source !== "wow335-progression") return; const rank = shamanTalentRanksForActor(work, source.actorId)?.[ WOW335_SHAMAN_LIGHTNING_OVERLOAD_TALENT_ID ] ?? 0; const profile = wow335ShamanLightningOverloadProfileForRank(rank); if (!wow335ShamanLightningOverloadShouldProc( profile, ability.name, landed, critical, periodic, stableCombatUnit(`${seed}:${WOW335_SHAMAN_LIGHTNING_OVERLOAD_PROC_TAG}`), ) || !profile) return; const overloadAmount = wow335ShamanLightningOverloadDamageAmount(initiatingSpellAmount); if (overloadAmount <= 0) return; // Resolve a free half-damage child hit without re-entering the cast loop, so // it can use the spell's normal combat table but cannot recursively overload. resolveAndDamageMobInWork( work.classId, work, targetId, overloadAmount, now, { ...source, threatScale: profile.threatScale }, "spell", school, `${seed}:${WOW335_SHAMAN_LIGHTNING_OVERLOAD_PROC_TAG}:echo`, ability, false, ); } function maybeGrantShamanEarthShieldTidalWavesInWork( work: MutableCastWork, sourceActorId: string, now: number, ): void { const improvedEarthShieldRank = shamanTalentRankForActor( work, sourceActorId, "Improved Earth Shield", 3, ); const tidalWavesRank = shamanTalentRankForActor(work, sourceActorId, "Tidal Waves", 3); const cooldownAura = work.auras.find((aura) => aura.sourceId === sourceActorId && aura.targetId === sourceActorId && aura.definition.utilityTags?.includes(WOW335_SHAMAN_IMPROVED_EARTH_SHIELD_TIDAL_WAVES_ICD_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); if (!wow335ShamanImprovedEarthShieldShouldGrantTidalWaves( improvedEarthShieldRank, tidalWavesRank, cooldownAura?.appliedAt ?? null, now, )) return; work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:shaman:tidal-waves", name: "Tidal Waves", description: "An Earth Shield heal grants one Tidal Waves charge.", disposition: "buff", durationMs: WOW335_SHAMAN_TIDAL_WAVES_DURATION_MS, maxStacks: WOW335_SHAMAN_TIDAL_WAVES_MAX_CHARGES, initialStacks: 1, stackBehavior: "add", utilityTags: [WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG], }, sourceId: sourceActorId, targetId: sourceActorId, now, }).auras]; work.auras = [...applyCombatAura(work.auras, { definition: { id: `wow335:shaman:improved-earth-shield-tidal-waves-icd:${sourceActorId}`, name: "Improved Earth Shield Tidal Waves Cooldown", disposition: "buff", durationMs: WOW335_SHAMAN_IMPROVED_EARTH_SHIELD_TIDAL_WAVES_COOLDOWN_MS, utilityTags: [WOW335_SHAMAN_IMPROVED_EARTH_SHIELD_TIDAL_WAVES_ICD_TAG], }, sourceId: sourceActorId, targetId: sourceActorId, now, }).auras]; } function shamanElementalDevastationRankForActor(work: MutableCastWork, sourceActorId: string): number { if (sourceActorId === PLAYER_AGGRO_ID) { return shamanTalentRank(work, "Elemental Devastation", 3); } const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); return member?.classId === "shaman" ? shamanTalentRankFromRanks(member.talentRanks, "Elemental Devastation", 3) : 0; } function maybeProcShamanElementalDevastationInWork( work: MutableCastWork, sourceActorId: string, ability: AbilityDefinition | undefined, attackKind: WotlkAttackKind, school: DamageSchool, critical: boolean, landed: boolean, periodic: boolean, now: number, ): void { if (!ability || ability.source === "runewaker" || attackKind !== "spell" || periodic || !critical || !landed || !["fire", "frost", "nature"].includes(school)) return; const profile = wow335ShamanElementalDevastationProfileForRank( shamanElementalDevastationRankForActor(work, sourceActorId), ); if (!profile) return; work.auras = [...applyCombatAura(work.auras, { definition: { id: `wow335:shaman:elemental-devastation:${sourceActorId}`, name: "Elemental Devastation", description: `Increases melee and ranged critical strike chance by ${profile.criticalChanceBonusPercent}% for ${profile.durationMs / 1_000} seconds after a direct Fire, Frost, or Nature spell critical hit.`, disposition: "buff", durationMs: profile.durationMs, stackBehavior: "refresh", utilityTags: [WOW335_SHAMAN_ELEMENTAL_DEVASTATION_AURA_TAG], modifiers: [{ kind: "stat", stat: "critical-strike", operation: "flat", value: profile.criticalChanceBonusPercent, }], }, sourceId: sourceActorId, targetId: sourceActorId, now, }).auras]; } const SHAMAN_SHOCK_NAMES = new Set(["Earth Shock", "Flame Shock", "Frost Shock"]); const SHAMAN_REVERBERATION_AURA_TAG = "wow335-shaman-reverberation"; const SHAMAN_ELEMENTAL_FOCUS_AURA_TAG = "wow335-shaman-elemental-focus-clearcasting"; const SHAMAN_RIPTIDE_ABILITY_ID = "wow335-shaman-riptide"; const SHAMAN_RIPTIDE_CHAIN_HEAL_BONUS = 1.25; function shamanRiptideHotOnTargetInWork( work: MutableCastWork, targetId: string, now: number, ): TimedCombatEffect | undefined { const effectTargetId = targetId === PLAYER_AURA_ENTITY_ID ? null : targetId; return work.effects.find((effect) => effect.kind === "hot" && effect.sourceActorId === PLAYER_AGGRO_ID && effect.sourceAbilityId === SHAMAN_RIPTIDE_ABILITY_ID && effect.targetId === effectTargetId && effect.remainingTicks > 0 && effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs > now); } function shamanClearcastingEligible(ability: AbilityDefinition): boolean { return !ability.passive && (ability.costs?.length ? ability.costs : [ability.cost]).some((cost) => cost.resource === "mana") && abilityAttackKind("shaman", ability) === "spell" && ["fire", "frost", "nature"].includes(abilityDamageSchool("shaman", ability)); } function shamanReverberationSourceTag(ability: AbilityDefinition): string { return `${SHAMAN_REVERBERATION_AURA_TAG}:from:${ability.id}`; } function maybeProcShamanElementalFocusInWork( work: MutableCastWork, ability: AbilityDefinition | undefined, school: DamageSchool, critical: boolean, periodic: boolean, seed: string, now: number, ): void { if (work.classId !== "shaman" || !ability || ability.source === "runewaker" || !critical || periodic || !["fire", "frost", "nature"].includes(school)) return; const rank = shamanTalentRank(work, "Elemental Focus", 2); const chance = rank >= 2 ? 1 : rank === 1 ? 0.5 : 0; if (chance <= 0 || stableCombatUnit(`${seed}:wow335-shaman-elemental-focus`) >= chance) return; work.auras = [...applyCombatAura(work.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 30%.", disposition: "buff", durationMs: 15_000, maxStacks: 2, initialStacks: 2, stackBehavior: "replace", utilityTags: [SHAMAN_ELEMENTAL_FOCUS_AURA_TAG], modifiers: [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: -0.3, perStack: false, }], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } function maybeProcShamanMaelstromWeaponInWork( work: MutableCastWork, seed: string, now: number, weaponSpeedMs: number, ): void { if (work.classId !== "shaman") return; const rank = shamanTalentRank(work, "Maelstrom Weapon", 3); const profile = wow335ShamanMaelstromWeaponProfile(rank, weaponSpeedMs); if (!profile || stableCombatUnit(`${seed}:wow335-shaman-maelstrom-weapon`) >= profile.procChancePerWeaponHit) return; work.auras = [...applyCombatAura(work.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: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } function maybeProcShamanReverberationInWork( work: MutableCastWork, ability: AbilityDefinition | undefined, landedDamage: number, periodic: boolean, seed: string, now: number, ): void { if (work.classId !== "shaman" || !ability || ability.source === "runewaker" || !SHAMAN_SHOCK_NAMES.has(ability.name) || landedDamage <= 0 || periodic) return; const rank = shamanTalentRank(work, "Reverberation", 3); const chance = [0, 1 / 3, 2 / 3, 1][rank] ?? 0; if (chance <= 0 || stableCombatUnit(`${seed}:wow335-shaman-reverberation`) >= chance) return; work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:shaman:reverberation", name: "Reverberation", description: "Your next different Shaman Shock or Elemental Blast deals 20% more direct damage within 8 seconds.", disposition: "buff", durationMs: 8_000, stackBehavior: "replace", utilityTags: [SHAMAN_REVERBERATION_AURA_TAG, shamanReverberationSourceTag(ability)], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } function maybeProcShamanismAfterDamage( work: MutableCastWork, ability: AbilityDefinition | undefined, seed: string, now: number, ): void { if (work.classId !== "shaman" || !ability || !["Lightning Bolt", "Chain Lightning"].includes(ability.name)) return; const rank = shamanTalentRank(work, "Shamanism", 3); if (rank <= 0 || stableCombatUnit(`${seed}:wow335-shaman-shamanism`) >= rank / 3) return; for (const cooldownAbility of work.abilities.filter((candidate) => ( ["Elemental Mastery", "Thunderstorm"].includes(candidate.name) ))) { const readyAt = work.cooldowns[cooldownAbility.id] ?? 0; if (readyAt > now) work.cooldowns[cooldownAbility.id] = Math.max(now, readyAt - 1_000); } } function maybeProcShamanImprovedWaterShieldInWork( work: MutableCastWork, ability: AbilityDefinition, targetId: string, school: DamageSchool, critical: boolean, now: number, ): void { if (work.classId !== "shaman" || ability.source === "runewaker") return; const rank = shamanTalentRank(work, "Improved Water Shield", 3); if (!wow335ShamanImprovedWaterShieldShouldProc( rank, ability.name, school, critical, stableCombatUnit(`${ability.id}:${targetId}:${now}:wow335-shaman-improved-water-shield`), )) return; const requested = wow335ShamanWaterShieldOrbManaAtLevel( work.progression.level, shamanTalentRank(work, "Improved Shields", 3), ); if (requested <= 0) return; const maximum = work.maxResourcePools.mana ?? defaultResourceMaximum("mana"); const current = work.resourcePools.mana ?? 0; const restored = wow335ShamanImprovedWaterShieldCappedRestore(current, maximum, requested); if (restored <= 0) return; work.resourcePools.mana = current + restored; if (resourceProfileForClass(work.classId).type === "mana") work.resource = work.resourcePools.mana; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AGGRO_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "wow335-shaman-improved-water-shield", rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false, }); } function consumeShamanTidalWavesChargeInAuras( auras: readonly ActiveAura[], instanceId: string, ): ActiveAura[] { return auras.flatMap((aura) => { if (aura.instanceId !== instanceId) return [aura]; return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; }); } function maybeProcShamanTidalWavesInWork( work: MutableCastWork, ability: AbilityDefinition, rawHealingAmount: number, now: number, ): void { if (work.classId !== "shaman" || ability.source === "runewaker" || rawHealingAmount <= 0) return; const charges = wow335ShamanTidalWavesChargesForTrigger(ability.name); if (charges <= 0) return; const rank = shamanTalentRank(work, "Tidal Waves", 3); const chance = wow335ShamanTidalWavesProcChance(rank); if (chance <= 0 || stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${now}:wow335-shaman-tidal-waves`) >= chance) return; work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:shaman:tidal-waves", name: "Tidal Waves", description: "Tidal Waves charges reduce Healing Wave cast time or increase Lesser Healing Wave critical chance.", disposition: "buff", durationMs: WOW335_SHAMAN_TIDAL_WAVES_DURATION_MS, maxStacks: WOW335_SHAMAN_TIDAL_WAVES_MAX_CHARGES, initialStacks: charges, stackBehavior: "add", utilityTags: [WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } function maybeProcShamanHealingTalentsInWork( work: MutableCastWork, ability: AbilityDefinition, targetId: string, playerPosition: CombatPosition, rawHealingAmount: number, school: DamageSchool, critical: boolean, now: number, ): void { if (work.classId !== "shaman" || ability.source === "runewaker") return; maybeProcShamanImprovedWaterShieldInWork(work, ability, targetId, school, critical, now); if (!critical) return; const ancestralHealingRank = shamanTalentRank(work, "Ancestral Healing", 3); if (ancestralHealingRank > 0 && school === "nature") { const reduction = [0, 0.03, 0.07, 0.1][ancestralHealingRank] ?? 0; work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:shaman:ancestral-healing", name: "Ancestral Healing", disposition: "buff", durationMs: 15_000, stackBehavior: "replace", utilityTags: ["wow335-shaman-ancestral-healing"], modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -reduction, }], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId, now, }).auras]; } if (!["Healing Wave", "Lesser Healing Wave", "Riptide"].includes(ability.name)) return; const awakeningRank = shamanTalentRank(work, "Ancestral Awakening", 3); if (awakeningRank <= 0 || rawHealingAmount <= 0) return; const partyMembers = usePartyStore.getState().members; const candidates = [ { id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: playerPosition }, ...partyMembers.map((member) => ({ id: member.id, health: member.health, maxHealth: member.maxHealth, position: getPartyRuntimePosition(member.id), })), ].filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth && candidate.position !== null && Math.sqrt(distanceSquared(playerPosition, candidate.position)) <= 40 && combatHasLineOfSight(playerPosition, candidate.position)) .sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth || left.id.localeCompare(right.id)); const target = candidates[0]; if (!target) return; const requested = Math.max(1, Math.round(rawHealingAmount * ([0, 0.1, 0.2, 0.3][awakeningRank] ?? 0))); const healed = target.id === PLAYER_AURA_ENTITY_ID ? healPlayerInWork(work, requested) : healCompanionActorInWork(work, target.id, requested); if (healed <= 0) return; work.totalHealing += healed; recordCombatEventInWork(work, { occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID, targetActorId: target.id, abilityId: "wow335-shaman-ancestral-awakening", school: "nature", rawAmount: requested, effectiveAmount: healed, overheal: requested - healed, absorbed: 0, critical: false, }); } const ROGUE_RUTHLESSNESS_COOLDOWN_AURA: AuraDefinition = { id: "wow335:rogue:ruthlessness-cooldown", name: "Ruthlessness Cooldown", disposition: "buff", durationMs: 9_000, hideFromAuraStrip: true, utilityTags: ["wow335-rogue-ruthlessness-cooldown"], }; const ROGUE_HONOR_AMONG_THIEVES_COOLDOWN_AURA: AuraDefinition = { id: "wow335:rogue:honor-among-thieves-cooldown", name: "Honor Among Thieves Cooldown", disposition: "buff", durationMs: 3_000, hideFromAuraStrip: true, utilityTags: ["wow335-rogue-honor-among-thieves-cooldown"], }; function rogueTalentRank(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank ), 0))); } function maybeProcRogueCombatPotencyInWork( work: MutableCastWork, seed: string, now: number, ): void { if (work.classId !== "rogue") return; const rank = rogueTalentRank(work.talentRanks, "Combat Potency", 3); if (rank <= 0) return; if (rank === 3) { for (const ability of work.abilities) { if (!ROGUE_COMBAT_POTENCY_COOLDOWN_ABILITIES.has(ability.name)) continue; const readyAt = work.cooldowns[ability.id] ?? now; if (readyAt > now) work.cooldowns[ability.id] = Math.max(now, readyAt - 1_000); } } const procChance = [0, 0.33, 0.66, 1][rank] ?? 0; if (stableCombatUnit(`${seed}:rogue-combat-potency`) >= procChance) return; const maximum = work.maxResourcePools.energy ?? work.maxResource; const current = work.resourcePools.energy ?? work.resource; const restored = Math.max(0, Math.min(ROGUE_COMBAT_POTENCY_ENERGY, maximum - current)); work.resourcePools.energy = current + restored; if (resourceProfileForClass(work.classId).type === "energy") work.resource = current + restored; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "wow335-rogue-combat-potency", rawAmount: ROGUE_COMBAT_POTENCY_ENERGY, effectiveAmount: restored, overheal: ROGUE_COMBAT_POTENCY_ENERGY - restored, absorbed: 0, critical: false, }); } function grantRogueComboPointInWork(work: MutableCastWork, targetId: string): void { work.comboPoints = clamp((work.comboTargetId === targetId ? work.comboPoints : 0) + 1, 0, 5); work.comboTargetId = targetId; } function maybeProcRogueCastTalentsInWork( work: MutableCastWork, ability: AbilityDefinition, targetId: string | null, comboPointsBeforeCast: number, comboTargetBeforeCast: string | null, now: number, ): void { if (work.classId !== "rogue" || !targetId || !work.mobs[targetId] || work.mobs[targetId]!.dead) return; const initiativeRank = ["Ambush", "Garrote", "Cheap Shot"].includes(ability.name) ? rogueTalentRank(work.talentRanks, "Initiative", 3) : 0; if (initiativeRank > 0 && stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${targetId}:${now}:rogue-initiative`) < initiativeRank / 3) { grantRogueComboPointInWork(work, targetId); } // The generated rank-3 tooltip describes this capstone as a combo point // from the Rogue's own direct-damage abilities, with a three-second limit. // The combat log's cast events are the right boundary: periodic-only spells // must not proc it on later ticks, and an area cast can grant at most once. const honorAmongThievesRank = rogueTalentRank(work.talentRanks, "Honor Among Thieves", 3); const directDamageAbility = ability.effects.some((effect) => ( effect.kind === "damage" || effect.kind === "finisher-damage" )); const honorAmongThievesOnCooldown = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-rogue-honor-among-thieves-cooldown") && (aura.expiresAt === null || aura.expiresAt > now)); if (honorAmongThievesRank >= 3 && directDamageAbility && work.totalDamage > 0 && work.comboPoints < 5 && !honorAmongThievesOnCooldown) { grantRogueComboPointInWork(work, targetId); work.auras = [...applyCombatAura(work.auras, { definition: ROGUE_HONOR_AMONG_THIEVES_COOLDOWN_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } const meleeFinisher = abilityAttackKind(work.classId, ability) === "melee" && ability.effects.some((effect) => effect.kind === "finisher-damage" || (effect.kind === "combo" && effect.amount < 0)); if (!meleeFinisher || comboPointsBeforeCast <= 0 || comboTargetBeforeCast !== targetId) return; const ruthlessnessRank = rogueTalentRank(work.talentRanks, "Ruthlessness", 3); const onCooldown = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-rogue-ruthlessness-cooldown") && (aura.expiresAt === null || aura.expiresAt > now)); if (ruthlessnessRank <= 0 || onCooldown || stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${targetId}:${now}:rogue-ruthlessness`) >= ruthlessnessRank / 3) return; grantRogueComboPointInWork(work, targetId); work.auras = [...applyCombatAura(work.auras, { definition: ROGUE_RUTHLESSNESS_COOLDOWN_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } function previousEligibleHotStreakCritical( events: readonly StructuredCombatEvent[], sourceActorId: string, currentAbility: AbilityDefinition, now: number, ): boolean { for (let index = events.length - 2; index >= 0; index -= 1) { const event = events[index]!; if (event.kind !== "damage" || event.sourceActorId !== sourceActorId) continue; // Several targets can receive the same area cast at the same timestamp; // those hits count as one cast for Hot Streak's consecutive-critical rule. if (event.occurredAt === now && event.abilityId === currentAbility.id) continue; const previousAbility = event.abilityId ? abilityById(event.abilityId) : null; return Boolean(event.critical && event.effectiveAmount > 0 && previousAbility && MAGE_HOT_STREAK_CRITICAL_SPELLS.has(previousAbility.name)); } return false; } function maybeProcRomMageMeditativeCurrentInWork( work: MutableCastWork, ability: AbilityDefinition | undefined, sourceActorId: string, attackKind: WotlkAttackKind, periodic: boolean, landed: boolean, now: number, ): void { if (work.classId !== "rom-mage" || sourceActorId !== PLAYER_AGGRO_ID || attackKind !== "spell" || periodic || !landed || !ability) return; const passiveLearned = work.abilities.some((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-mage" && candidate.dbcSpellId === ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId && candidate.passive && (!candidate.secondaryClassId || candidate.secondaryClassId === work.secondaryClassId) && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= work.progression.level); const readyAt = romMageMeditativeCurrentReadyAt(work.auras, sourceActorId, now); const proc = romMageMeditativeCurrentProc( work.classId, ability, passiveLearned ? [ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId] : [], landed, readyAt, now, ); if (proc.effect?.kind !== "apply-aura") return; for (const effect of [proc.effect, ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT]) { work.auras = [...applyCombatAura(work.auras, { definition: auraDefinitionForAbility(effect.aura, ability), sourceId: sourceActorId, targetId: sourceActorId, now, }).auras]; } } function maybeProcRomMageKnightLightOfDayInWork( work: MutableCastWork, ability: AbilityDefinition | undefined, sourceActorId: string, attackKind: WotlkAttackKind, periodic: boolean, landed: boolean, now: number, ): void { if (attackKind !== "spell" || periodic || !landed || !ability) return; const partyMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const characterClassId = partyMember?.classId ?? work.classId; const secondaryClassId = partyMember ? partyMember.secondaryClassId : work.secondaryClassId; if (characterClassId !== "rom-mage" || secondaryClassId !== "rom-knight") return; const level = partyMember?.level ?? work.progression.level; const talentRanks = partyMember?.talentRanks ?? work.talentRanks; const passiveRow = abilitiesForCharacter(characterClassId, secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.passive && candidate.dbcSpellId === ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId )); const learnedPassive = passiveRow && learnedAbilityForBuild(passiveRow, level, talentRanks); const readyAt = romMageKnightLightOfDayReadyAt(work.auras, sourceActorId, now); const proc = romMageKnightLightOfDayProc( characterClassId, secondaryClassId, ability, learnedPassive ? [ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId] : [], landed, readyAt, now, ); if (proc.effect?.kind !== "apply-aura") return; for (const effect of [proc.effect, ROM_MAGE_KNIGHT_LIGHT_OF_DAY_COOLDOWN_EFFECT]) { work.auras = [...applyCombatAura(work.auras, { definition: auraDefinitionForAbility(effect.aura, ability), sourceId: sourceActorId, targetId: sourceActorId, now, }).auras]; } } function maybeProcRomScoutPriestDrumsOfWarInWork( work: MutableCastWork, ability: AbilityDefinition | undefined, sourceActorId: string, attackKind: WotlkAttackKind, periodic: boolean, landed: boolean, now: number, ): void { if (periodic || attackKind !== "ranged" || !landed || !ability) return; const partyMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const characterClassId = partyMember ? partyMember.classId : work.classId; const secondaryClassId = partyMember ? partyMember.secondaryClassId : work.secondaryClassId; if (characterClassId !== "rom-scout" || secondaryClassId !== "rom-priest") return; const level = partyMember?.level ?? work.progression.level; const talentRanks = partyMember?.talentRanks ?? work.talentRanks; const passiveRow = abilitiesForCharacter(characterClassId, secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.passive && candidate.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId )); const learnedPassive = passiveRow && learnedAbilityForBuild(passiveRow, level, talentRanks); const cooldownKey = `${ROM_SCOUT_PRIEST_DRUMS_OF_WAR.cooldownKey}:${sourceActorId}`; const proc = romScoutPriestDrumsOfWarProc( characterClassId, secondaryClassId, ability, learnedPassive ? [ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId] : [], landed, now, work.cooldowns[cooldownKey] ?? 0, attackKind, ); if (!proc) return; const combatState = useCombatStore.getState(); const sourcePosition = sourceActorId === PLAYER_AGGRO_ID ? combatState.playerPosition : getPartyRuntimePosition(sourceActorId); if (!sourcePosition) return; const partyMembers = usePartyStore.getState().members; const recipients = [ { id: PLAYER_AURA_ENTITY_ID, health: work.health, position: combatState.playerPosition, }, ...partyMembers.map((member) => ({ id: member.id, health: work.actors[member.id]?.health ?? member.health, position: getPartyRuntimePosition(member.id), })), ].filter((candidate) => candidate.health > 0 && candidate.position && Math.sqrt(distanceSquared(sourcePosition, candidate.position)) <= proc.radius); if (recipients.length === 0) return; work.cooldowns[cooldownKey] = now + proc.internalCooldownMs; for (const recipient of recipients) { work.auras = [...applyCombatAura(work.auras, { definition: ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA, sourceId: sourceActorId, targetId: recipient.id, now, }).auras]; } } function applyRomRoguePoisonOnNormalAttackInWork( work: MutableCastWork, sourceActorId: string, targetId: string, attackKind: WotlkAttackKind, attackLanded: boolean, seed: string, now: number, ): void { if (work.classId !== "rom-rogue" || !attackLanded || work.mobs[targetId]?.dead) return; const poisonSkill = work.abilities.find((ability) => ability.dbcSpellId === ROM_ROGUE_POISON_SKILL_ID); if (!poisonSkill || !learnedAbilityForBuild(poisonSkill, work.progression.level, work.talentRanks)) return; const poisonActive = aurasForEntity(work.auras, sourceActorId).some((aura) => ( aura.definition.utilityTags?.includes(ROM_ROGUE_POISON_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )); const currentSpeedAura = work.auras.find((aura) => aura.sourceId === sourceActorId && aura.targetId === sourceActorId && aura.definition.utilityTags?.includes(ROM_ROGUE_POISON_ATTACK_SPEED_TAG)); const result = romRoguePoisonAttackResultForAttack({ classId: work.classId, sourceActorId, targetActorId: targetId, attackKind, isNormalAttack: true, attackLanded, poisonActive, roll: stableCombatUnit(`${seed}:rom-rogue-poison`), attackSpeedTargetId: currentSpeedAura ? romRoguePoisonAttackSpeedTargetId(currentSpeedAura) : null, targetPoisonStacks: work.auras.find((aura) => aura.sourceId === sourceActorId && aura.targetId === targetId && aura.definition.utilityTags?.includes("rom-poisonous-hands-debuff"))?.stacks ?? 0, }); if (!result) return; if (result.resetAttackSpeed) work.auras = work.auras.filter((aura) => !(aura.sourceId === sourceActorId && aura.targetId === sourceActorId && aura.definition.utilityTags?.includes(ROM_ROGUE_POISON_ATTACK_SPEED_TAG))); if (result.application) work.auras = [...applyCombatAura(work.auras, { definition: result.application.definition, sourceId: result.application.sourceId, targetId: result.application.targetId, stacks: result.application.stacks, now, }).auras]; if (result.attackSpeedApplication) work.auras = [...applyCombatAura(work.auras, { definition: result.attackSpeedApplication.definition, sourceId: result.attackSpeedApplication.sourceId, targetId: result.attackSpeedApplication.targetId, stacks: result.attackSpeedApplication.stacks, now, }).auras]; } function deathKnightTalentOwnerForActor( work: Pick, sourceActorId: string, ): { readonly talentRanks: TalentRanks } | null { if (sourceActorId === PLAYER_AGGRO_ID) { return work.classId === "death-knight" ? { talentRanks: work.talentRanks } : null; } const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); return member?.classId === "death-knight" ? { talentRanks: member.talentRanks } : null; } function paladinTalentOwnerForActor( work: Pick, sourceActorId: string, ): { readonly talentRanks: TalentRanks } | null { if (sourceActorId === PLAYER_AGGRO_ID) { return work.classId === "paladin" ? { talentRanks: work.talentRanks } : null; } const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); return member?.classId === "paladin" ? { talentRanks: member.talentRanks } : null; } function hunterTalentOwnerForActor( work: Pick, sourceActorId: string, ): { readonly talentRanks: TalentRanks } | null { if (sourceActorId === PLAYER_AGGRO_ID) { return work.classId === "hunter" ? { talentRanks: work.talentRanks } : null; } const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); return member?.classId === "hunter" ? { talentRanks: member.talentRanks } : null; } function maybeProcHunterGoForTheThroatInWork( work: MutableCastWork, sourceActorId: AggroActorId, talentRanks: TalentRanks | null, attackKind: WotlkAttackKind, abilityId: string, critical: boolean, landed: boolean, periodic: boolean, now: number, ): void { if (!talentRanks || !critical || !landed || periodic) return; if (wow335HunterGoForTheThroatAttackKindForAbility(abilityId) !== attackKind) return; const rank = Math.max(0, Math.min(2, Math.trunc(talentRanks[WOW335_HUNTER_GO_FOR_THE_THROAT_TALENT_ID] ?? 0))); const mechanics = wow335HunterGoForTheThroatRankMechanics(rank); if (!mechanics) return; const activePet = work.summons.find((summon) => summon.ownerActorId === sourceActorId && Boolean(summon.hunterPet) && summon.health > 0 && summon.expiresAt > now); if (!activePet) return; const focus = activePet.resources.focus ?? normalizeActorResourcePool("focus", 100, 100, 5, now); const lockout = work.auras.find((aura) => aura.sourceId === sourceActorId && aura.targetId === sourceActorId && aura.definition.utilityTags?.includes(WOW335_HUNTER_GO_FOR_THE_THROAT_ICD_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); const plan = wow335HunterGoForTheThroatProcPlan(rank, { attackType: attackKind === "melee" || attackKind === "ranged" ? attackKind : "other", critical, hasLivingActivePet: true, focusCurrent: focus.current, focusMaximum: focus.maximum, lastProcAt: lockout?.appliedAt ?? null, now, }); if (!plan) return; const focusAfter = { ...focus, current: plan.focusAfter }; work.summons = work.summons.map((summon) => summon.id === activePet.id ? { ...summon, resources: { ...summon.resources, focus: focusAfter } } : summon); const petActor = work.actors[activePet.id]; if (petActor) { work.actors[activePet.id] = { ...petActor, resources: { ...petActor.resources, focus: focusAfter }, }; } work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:hunter:go-for-the-throat-icd", name: "Go for the Throat Internal Cooldown", description: mechanics.description, disposition: "buff", durationMs: mechanics.internalCooldownMs, utilityTags: [WOW335_HUNTER_GO_FOR_THE_THROAT_ICD_TAG], }, sourceId: sourceActorId, targetId: sourceActorId, now, }).auras]; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId, targetActorId: activePet.id, abilityId: WOW335_HUNTER_GO_FOR_THE_THROAT_TALENT_ID, rawAmount: mechanics.focusPerProc, effectiveAmount: plan.focusGain, overheal: mechanics.focusPerProc - plan.focusGain, absorbed: 0, critical: false, }); } function mageFrostWardingIncomingReduction(input: { readonly classId: ClassId; readonly talentRank: number; readonly auras: readonly ActiveAura[]; readonly targetId: string; readonly attackKind: WotlkAttackKind; readonly damageSchool: DamageSchool; readonly spellDamage: number; readonly roll: number; readonly now: number; }): { readonly damageAfterReduction: number; readonly manaRefund: number; readonly wardAbilityId: string } | null { if (input.classId !== "mage" || input.attackKind !== "spell") return null; const wardRows = [ { ...WOW335_MAGE_FROST_WARDING_WARD_ABILITIES.fire, name: "Fire Ward" }, { ...WOW335_MAGE_FROST_WARDING_WARD_ABILITIES.frost, name: "Frost Ward" }, ]; const wardAura = input.auras.find((aura) => aura.targetId === input.targetId && (aura.expiresAt === null || aura.expiresAt > input.now) && wardRows.some((ward) => aura.definition.name === ward.name || aura.definition.name.startsWith(`${ward.name} (`))); if (!wardAura) return null; const ward = wardRows.find((candidate) => wardAura.definition.name === candidate.name || wardAura.definition.name.startsWith(`${candidate.name} (`)); const rankSpellMatch = wardAura.definition.id.match(/^wow335:(\d+):/); const wardRankSpellId = rankSpellMatch ? Number(rankSpellMatch[1]) : 0; if (!ward || !Number.isInteger(wardRankSpellId)) return null; const plan = wow335MageFrostWardingProcPlan(input.talentRank, { wardOwnerClassId: input.classId, eventKind: input.attackKind === "spell" ? "spell-damage" : input.attackKind, wardAbilityId: ward.abilityId, wardRankSpellId, wardAuraActive: true, damageSchool: input.damageSchool, spellDamage: input.spellDamage, roll: input.roll, }); if (!plan) return null; const reduced = Math.round(input.spellDamage * plan.wardDamageReductionPercent / 100); return { damageAfterReduction: Math.max(0, input.spellDamage - reduced), manaRefund: reduced * plan.manaRefundPerDamageReduced, wardAbilityId: plan.wardAbilityId, }; } function deathKnightDancingRuneWeaponActiveInWork( work: Pick, sourceActorId: string, now: number, ): boolean { if (!deathKnightTalentOwnerForActor(work, sourceActorId)) return false; const auraTargetId = sourceActorId === PLAYER_AGGRO_ID ? PLAYER_AURA_ENTITY_ID : sourceActorId; return work.auras.some((aura) => aura.targetId === auraTargetId && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now) && wow335DeathKnightDancingRuneWeaponIsActive(aura.appliedAt, now)); } function maybeMirrorDeathKnightDancingRuneWeaponMeleeInWork( work: MutableCastWork, targetId: string, rawOwnerDamage: number, now: number, source: ThreatSource, school: DamageSchool, ): DamageResult | null { const target = work.mobs[targetId]; if (!deathKnightDancingRuneWeaponActiveInWork(work, source.actorId, now) || !wow335DeathKnightDancingRuneWeaponCanMirrorEvent({ kind: "melee", targetAlive: Boolean(target && !target.dead), targetHostile: Boolean(target), })) return null; const mirroredDamage = wow335DeathKnightDancingRuneWeaponDamage(rawOwnerDamage); if (mirroredDamage <= 0) return null; return damageMobInWork( work.classId, work, targetId, mirroredDamage, now, source, school, work.gearStats.spellPenetration, ); } function maybeMirrorDeathKnightDancingRuneWeaponSpellInWork( work: MutableCastWork, targetId: string, rawOwnerDamage: number, now: number, source: ThreatSource, ability: AbilityDefinition, school: DamageSchool, isAreaTargeted: boolean, actionTargetIsOwner: boolean, ): DamageResult | null { const target = work.mobs[targetId]; const hasSummonEffect = ability.effects.some((effect) => effect.kind === "summon" || effect.kind === "pet"); const event: Wow335DeathKnightDancingRuneWeaponOwnerEvent = { kind: "spell", spellId: ability.dbcSpellId, ownerKnowsSpell: true, targetAlive: Boolean(target && !target.dead), targetHostile: Boolean(target), isPositiveSpell: ability.target === "friendly" || ability.target === "self", hasSummonEffect, isAreaTargeted, actionTargetIsOwner, excludedUtilitySpell: wow335DeathKnightDancingRuneWeaponExcludedUtilitySpellForName(ability.name), }; if (!deathKnightDancingRuneWeaponActiveInWork(work, source.actorId, now) || !wow335DeathKnightDancingRuneWeaponCanMirrorEvent(event)) return null; const mirroredDamage = wow335DeathKnightDancingRuneWeaponDamage(rawOwnerDamage); if (mirroredDamage <= 0) return null; return damageMobInWork( work.classId, work, targetId, mirroredDamage, now, { ...source, abilityId: ability.id }, school, work.gearStats.spellPenetration, ); } function maybeProcDeathKnightBloodCakedBladeInWork( work: MutableCastWork, targetId: string, amount: number, now: number, source: ThreatSource, attackKind: WotlkAttackKind, seed: string, periodic: boolean, rules: CombatResultRules, primaryHit: CombatResult, weaponSpeedMs: number, ): DamageResult | null { const owner = deathKnightTalentOwnerForActor(work, source.actorId); const rank = owner?.talentRanks[WOW335_DEATH_KNIGHT_BLOOD_CAKED_BLADE_TALENT_ID] ?? 0; const chance = wow335DeathKnightBloodCakedBladeProcChance(rank); if (chance <= 0 || attackKind !== "melee" || periodic || !rules.isAutoAttack || !primaryHit.landed || primaryHit.rawDamage <= 0 || !wow335DeathKnightBloodCakedBladeShouldProc( rank, stableCombatUnit(`${seed}:${WOW335_DEATH_KNIGHT_BLOOD_CAKED_BLADE_PROC_TAG}`), )) return null; const diseaseTags = new Set(["blood-plague", "frost-fever"]); const diseaseCount = new Set(work.effects.filter((effect) => ( effect.kind === "dot" && effect.sourceActorId === source.actorId && effect.targetId === targetId && effect.remainingTicks > 0 && effect.tag !== undefined && diseaseTags.has(effect.tag) )).map((effect) => effect.kind === "dot" ? effect.tag : undefined)).size; const strikeAmount = amount * wow335DeathKnightBloodCakedStrikeDamageCoefficient(diseaseCount); if (strikeAmount <= 0) return null; return resolveAndDamageMobInWork( "death-knight", work, targetId, strikeAmount, now, { ...source, abilityId: WOW335_DEATH_KNIGHT_BLOOD_CAKED_STRIKE_ABILITY_ID }, "melee", "physical", `${seed}:${WOW335_DEATH_KNIGHT_BLOOD_CAKED_STRIKE_ABILITY_ID}`, undefined, false, { canCrit: false }, 0, weaponSpeedMs, ); } function resolveAndDamageMobInWork( classId: ClassId, work: MutableCastWork, id: string, amount: number, now: number, source: ThreatSource, attackKind: WotlkAttackKind, school: DamageSchool, seed: string, ability?: AbilityDefinition, periodic = false, rules: CombatResultRules = {}, criticalChanceBonusPercent = 0, weaponSpeedMs = work.gearStats.meleeWeapon?.speedMs ?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS, selectedDbcSpellId?: number, ): DamageResult { const mob = work.mobs[id]; if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) { return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 }; } const sourcePartyMember = source.actorId === PLAYER_AGGRO_ID ? undefined : usePartyStore.getState().members.find((member) => member.id === source.actorId); const sourceClassId = sourcePartyMember?.classId ?? classId; const bloodGorgedOwner = sourceClassId === "death-knight" ? deathKnightTalentOwnerForActor(work, source.actorId) : null; const bloodGorgedRank = bloodGorgedOwner?.talentRanks[WOW335_DEATH_KNIGHT_BLOOD_GORGED_TALENT_ID] ?? 0; const bloodGorgedSpellId = selectedDbcSpellId ?? ability?.dbcSpellId; const bloodGorgedSpellFamily = !periodic && bloodGorgedRank > 0 && bloodGorgedSpellId !== undefined ? wow335DeathKnightSpellFamilyInfoForSpellId(bloodGorgedSpellId) : null; const bloodGorgedCurrentHealth = sourcePartyMember?.health ?? (source.actorId === PLAYER_AGGRO_ID ? work.health : 0); const bloodGorgedMaximumHealth = sourcePartyMember?.maxHealth ?? (source.actorId === PLAYER_AGGRO_ID ? work.maxHealth : 0); const bloodGorgedDamageBonusPercent = bloodGorgedSpellFamily ? wow335DeathKnightBloodGorgedDamageBonusPercent( bloodGorgedRank, bloodGorgedCurrentHealth, bloodGorgedMaximumHealth, bloodGorgedSpellFamily.spellFamilyName, bloodGorgedSpellFamily.spellFamilyFlags, ) : 0; const bloodGorgedArmorPenetrationPercent = bloodGorgedSpellFamily ? wow335DeathKnightBloodGorgedArmorPenetrationPercent( bloodGorgedRank, bloodGorgedSpellFamily.spellFamilyName, bloodGorgedSpellFamily.spellFamilyFlags, ) : 0; const improvedVictoryRushRank = sourcePartyMember?.classId === "warrior" ? sourcePartyMember.talentRanks[WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_TALENT_ID] ?? 0 : !sourcePartyMember && classId === "warrior" ? work.talentRanks[WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_TALENT_ID] ?? 0 : 0; const sourceWeaponChannel = !periodic && rules.isAutoAttack ? attackKind === "ranged" ? "ranged" : attackKind === "melee" ? source.abilityId === PLAYER_OFFHAND_AUTO_ATTACK_ID || source.abilityId === "party-auto-attack-offhand" ? "off-hand" : "main-hand" : undefined : !periodic && ability?.wowSpellDefense ? enemyDisarmAbilityWeaponChannel({ ...ability.wowSpellDefense, equippedItemSubclassMask: ability.wowWeaponRequirements?.equippedItemSubclassMask, }) : undefined; const sourceAuras = effectiveActorAuras(work.auras, source.actorId, now, sourceWeaponChannel); const activeSourceAuras = sourceAuras.filter((aura) => aura.expiresAt === null || aura.expiresAt > now); const saintsBlessingDamageMultiplier = romPriestScoutSaintsBlessingMultiplierForHit( work, classId, source.actorId, mob, sourcePartyMember, ); const paladinVengeanceOwner = sourceClassId === "paladin" ? paladinTalentOwnerForActor(work, source.actorId) : null; const paladinVengeanceRank = paladinVengeanceOwner?.talentRanks[WOW335_PALADIN_VENGEANCE_TALENT_ID] ?? 0; const paladinVengeanceAura = activeSourceAuras.find((aura) => aura.definition.utilityTags?.includes( WOW335_PALADIN_VENGEANCE_AURA_TAG, )); const hunterTalentOwner = sourceClassId === "hunter" ? hunterTalentOwnerForActor(work, source.actorId) : null; const paladinVengeanceBonusPercent = wow335PaladinVengeanceDamageBonusPercent( paladinVengeanceRank, paladinVengeanceAura?.stacks ?? 0, { casterClassId: sourceClassId, abilityId: ability?.id ?? source.abilityId ?? "", damageSchool: school }, ); const druidMasterShapeshifterRank = sourcePartyMember?.classId === "druid" ? sourcePartyMember.talentRanks[WOW335_DRUID_MASTER_SHAPESHIFTER_TALENT_ID] ?? 0 : !sourcePartyMember && sourceClassId === "druid" && source.actorId === PLAYER_AGGRO_ID ? work.talentRanks[WOW335_DRUID_MASTER_SHAPESHIFTER_TALENT_ID] ?? 0 : 0; const druidMasterShapeshifterForm = druidMasterShapeshifterRank > 0 ? wow335DruidShapeshiftFormForAuras(work.auras, source.actorId, now) : null; const druidMasterShapeshifterModifiers = wow335DruidMasterShapeshifterModifiersForForm( druidMasterShapeshifterRank, druidMasterShapeshifterForm, ); const druidMasterShapeshifterDamageMultiplier = druidMasterShapeshifterForm === "bear" && school === "physical" || druidMasterShapeshifterForm === "moonkin" && (school === "arcane" || school === "nature") ? applyTalentModifierValue(1, druidMasterShapeshifterModifiers, "damage-percent") : 1; const druidMasterShapeshifterCriticalBonus = !periodic && attackKind === "melee" && druidMasterShapeshifterForm === "cat" ? druidMasterShapeshifterModifiers.find((modifier) => modifier.kind === "critical-chance-percent")?.amount ?? 0 : 0; const improvedVictoryRushReadyAura = sourceClassId === "warrior" && ability?.name === "Victory Rush" ? activeSourceAuras.find((aura) => aura.definition.utilityTags?.includes( WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_READY_TAG, )) : undefined; const victoryRushKillReadyAura = sourceClassId === "warrior" && ability?.name === "Victory Rush" ? activeSourceAuras.find((aura) => aura.definition.utilityTags?.includes( WOW335_WARRIOR_VICTORY_RUSH_KILL_READY_TAG, )) : undefined; const adjustedThreatSource = improvedVictoryRushReadyAura ? { ...source, threatScale: (source.threatScale ?? 1) * wow335WarriorImprovedVictoryRushThreatScale( improvedVictoryRushRank, ability?.name ?? "", true, ), } : source; const reverberationAura = classId === "shaman" && source.actorId === PLAYER_AGGRO_ID && !periodic && ability && (SHAMAN_SHOCK_NAMES.has(ability.name) || ability.name === "Elemental Blast") ? activeSourceAuras.find((aura) => aura.definition.utilityTags?.includes(SHAMAN_REVERBERATION_AURA_TAG) && !aura.definition.utilityTags.includes(shamanReverberationSourceTag(ability))) : undefined; const fingersOfFrost = classId === "mage" && source.actorId === PLAYER_AGGRO_ID ? activeSourceAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_FINGERS_OF_FROST_TAG)) : undefined; const hotStreak = classId === "mage" && source.actorId === PLAYER_AGGRO_ID ? activeSourceAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG)) : undefined; const targetFrozen = mob.statuses.some((status) => status.kind === "freeze" && status.endsAt > now); const treatedAsFrozen = targetFrozen || Boolean(fingersOfFrost); const mageShatterOwner = sourceClassId === "mage" ? wow335MageTalentOwnerForActor(work, source.actorId) : null; const mageShatterTargetTreatedAsFrozen = targetFrozen || (sourceClassId === "mage" && activeSourceAuras.some((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_FINGERS_OF_FROST_TAG))); const mageShatterCriticalChanceBonus = !periodic && attackKind === "spell" && ability && mageShatterOwner ? wow335MageShatterCriticalChanceBonusPercent( mageTalentRank(mageShatterOwner.talentRanks, "Shatter", 3), mageShatterTargetTreatedAsFrozen, ) : 0; const mageProcDamageMultiplier = classId === "mage" && source.actorId === PLAYER_AGGRO_ID ? (ability?.name === "Ice Lance" && treatedAsFrozen ? 3 : 1) * (ability?.name === "Pyroblast" && hotStreak ? 1.4 : 1) : 1; const improvedScorchVulnerability = attackKind === "spell" && aurasForEntity(work.auras, id).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_MAGE_IMPROVED_SCORCH_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )); const improvedShadowBoltVulnerability = attackKind === "spell" && aurasForEntity(work.auras, id).some((aura) => ( aura.definition.utilityTags?.includes(WARLOCK_IMPROVED_SHADOW_BOLT_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )); const winterChillTargetAura = attackKind === "spell" ? aurasForEntity(work.auras, id).find((aura) => ( aura.definition.utilityTags?.includes(WOW335_MAGE_WINTERS_CHILL_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )) : undefined; const mageWintersChillTargetCriticalBonus = sourceClassId === "mage" && attackKind === "spell" ? wow335MageWintersChillTargetModifiers(winterChillTargetAura?.stacks ?? 0).criticalChanceBonusPercent : 0; const warlockNightfallReady = classId === "warlock" && source.actorId === PLAYER_AGGRO_ID && activeSourceAuras.some((aura) => aura.definition.utilityTags?.includes(WARLOCK_NIGHTFALL_READY_TAG)); const combustion = !periodic && attackKind === "spell" && school === "fire" ? sourceAuras.find((aura) => aura.definition.utilityTags?.includes("fire-crit-stacking")) : undefined; const druidSolarEclipse = classId === "druid" && source.actorId === PLAYER_AGGRO_ID && (ability?.name === "Wrath" || ability?.name === "Starsurge") ? sourceAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-druid-eclipse-solar") && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const druidLunarEclipse = classId === "druid" && source.actorId === PLAYER_AGGRO_ID && (ability?.name === "Starfire" || ability?.name === "Starsurge") ? sourceAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-druid-eclipse-lunar") && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const daggerMasteryTags = sourceAuras .filter((aura) => aura.expiresAt === null || aura.expiresAt > now) .flatMap((aura) => aura.definition.utilityTags ?? []); const mainHandDagger = (() => { if (classId !== "rom-rogue" || source.actorId !== PLAYER_AURA_ENTITY_ID || !daggerMasteryTags.includes(ROM_ROGUE_DAGGER_MASTERY_TAG)) return false; const currentState = useCombatStore.getState(); return resolvedEquipmentLoadout(currentState.equipment, currentState.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: "rom-rogue", secondaryClassId: currentState.secondaryClassId, specializationId: currentState.specializationId, role: currentState.gearRole, level: currentState.level, })["main-hand"]?.weaponType === "dagger"; })(); const rogueDaggerMultiplier = romRogueDaggerDamageMultiplier({ classId, sourceActorId: source.actorId, playerActorId: PLAYER_AURA_ENTITY_ID, attackKind, school, mainHandDagger, passiveTags: daggerMasteryTags, }); const moltenFuryOwner = ability?.classId === "mage" && ability.source === "wow335-progression" ? wow335MageTalentOwnerForActor(work, source.actorId) : null; const moltenFuryAmount = moltenFuryOwner ? wow335MageMoltenFuryDamage( amount, moltenFuryOwner.talentRanks[WOW335_MAGE_MOLTEN_FURY_TALENT_ID] ?? 0, school, mob.health, mob.maxHealth, ) : amount; // Generic dealt-damage modifiers must ignore timed auras at their expiry // boundary; specialized proc checks above use the same filtered snapshot. const modifiedAmount = resolveAuraDamageValue(moltenFuryAmount, activeSourceAuras, { direction: "dealt", school, attackKind, }) * warlockNightfallDamageMultiplier(ability?.name ?? "", warlockNightfallReady) * (druidSolarEclipse ? 1.3 : 1) * rogueDaggerMultiplier * mageProcDamageMultiplier * (reverberationAura ? 1.2 : 1) * druidMasterShapeshifterDamageMultiplier * (1 + paladinVengeanceBonusPercent / 100) * saintsBlessingDamageMultiplier * (1 + bloodGorgedDamageBonusPercent / 100); const combatResult = resolvePlayerDamageResult( classId, work, source.actorId, now, mob, modifiedAmount, attackKind, school, seed, ability, periodic, rules, criticalChanceBonusPercent + (combustion ? Math.max(0, combustion.stacks - 1) : 0) + (druidLunarEclipse ? 30 : 0) + (improvedScorchVulnerability ? 5 : 0) + (improvedShadowBoltVulnerability ? 5 : 0) + mageWintersChillTargetCriticalBonus + mageShatterCriticalChanceBonus + druidMasterShapeshifterCriticalBonus, bloodGorgedArmorPenetrationPercent, selectedDbcSpellId, ); let godsSwordSecondaryDamage: SecondaryDamagePacket | undefined; const sourceSecondaryClassId = sourcePartyMember?.secondaryClassId ?? work.secondaryClassId; const chaosDaggerSecondaryClassId = sourcePartyMember ? sourcePartyMember.secondaryClassId : work.secondaryClassId; let chaosDaggerSecondaryDamage: SecondaryDamagePacket | undefined; if (!ability && !periodic && rules.isAutoAttack && attackKind === "melee" && combatResult.landed && sourceClassId === "rom-rogue" && chaosDaggerSecondaryClassId === "rom-mage" && (source.actorId === PLAYER_AGGRO_ID || sourcePartyMember)) { const learnedPassives = learnedRomCoaPassives( sourceClassId, sourceSecondaryClassId, sourcePartyMember?.level ?? work.progression.level, sourcePartyMember?.talentRanks ?? work.talentRanks, ); const weaponHand = source.abilityId === PLAYER_OFFHAND_AUTO_ATTACK_ID || source.abilityId === "party-auto-attack-offhand" ? "off-hand" : "main-hand"; const state = useCombatStore.getState(); const ownerWeapon = source.actorId === PLAYER_AGGRO_ID ? playerEquippedMeleeWeapon(state, weaponHand) ?? (weaponHand === "main-hand" ? work.gearStats.meleeWeapon : null) : sourcePartyMember ? weaponHand === "main-hand" ? sourcePartyMember.gearStats.meleeWeapon : resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: sourcePartyMember.id, ownerKind: "party", classId: sourcePartyMember.classId, secondaryClassId: sourcePartyMember.secondaryClassId, specializationId: sourcePartyMember.specializationId, role: sourcePartyMember.role, level: sourcePartyMember.level, })[weaponHand]?.stats.meleeWeapon ?? null : null; const bonus = romRogueChaosDaggerDamage({ classId: sourceClassId, secondaryClassId: chaosDaggerSecondaryClassId, passiveLearned: learnedPassives.includes(ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID), isAutoAttack: true, attackLanded: true, weaponHand, weaponDps: ownerWeapon ? weaponDps(ownerWeapon) : 0, }); if (bonus) { chaosDaggerSecondaryDamage = { amount: resolveAuraDamageValue(bonus.amount, activeSourceAuras, { direction: "dealt", school: bonus.school, attackKind: "melee", }), school: bonus.school, spellPenetration: sourcePartyMember?.gearStats.spellPenetration ?? work.gearStats.spellPenetration, attackKind: "melee", }; } } const physicalAbilityEvent = school === "physical" || (ability?.source === "runewaker" && /\bphysical(?:\s+light)?\s+damage\b/i.test(ability.description)); if (ability && !periodic && physicalAbilityEvent && combatResult.landed && sourceClassId === "rom-knight" && sourceSecondaryClassId === "rom-rogue") { const learnedPassiveSpellIds = learnedRomCoaPassives( sourceClassId, sourceSecondaryClassId, sourcePartyMember?.level ?? work.progression.level, sourcePartyMember?.talentRanks ?? work.talentRanks, ); if (learnedPassiveSpellIds.includes(ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID)) { const state = useCombatStore.getState(); const offhandWeapon = source.actorId === PLAYER_AGGRO_ID ? playerEquippedMeleeWeapon(state, "off-hand") : sourcePartyMember ? resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: sourcePartyMember.id, ownerKind: "party", classId: sourcePartyMember.classId, secondaryClassId: sourcePartyMember.secondaryClassId, specializationId: sourcePartyMember.specializationId, role: sourcePartyMember.role, level: sourcePartyMember.level, })["off-hand"]?.stats.meleeWeapon ?? null : null; const bonusLightDamage = romCoaKnightRogueGodsSwordBonusLightDamage({ classId: sourceClassId, secondaryClassId: sourceSecondaryClassId, learnedPassiveSpellIds, landedOffensivePhysicalAbilityDamageEvent: true, offhandWeaponDps: offhandWeapon ? weaponDps(offhandWeapon) : 0, }); if (bonusLightDamage > 0) { godsSwordSecondaryDamage = { amount: resolveAuraDamageValue(bonusLightDamage, activeSourceAuras, { direction: "dealt", school: "holy", attackKind: "spell", }), school: "holy", spellPenetration: sourcePartyMember?.gearStats.spellPenetration ?? work.gearStats.spellPenetration, }; } } } let result = damageMobInWork( classId, work, id, amount, now, adjustedThreatSource, school, work.gearStats.spellPenetration, combatResult, chaosDaggerSecondaryDamage ?? godsSwordSecondaryDamage, true, undefined, !periodic && rules.isAutoAttack ? sourceWeaponChannel === "main-hand" ? "mainhand" : sourceWeaponChannel === "off-hand" ? "offhand" : sourceWeaponChannel === "ranged" ? "ranged" : undefined : undefined, ); maybeProcShamanFlurryInWork( work, source.actorId, attackKind, combatResult.landed, combatResult.critical, periodic, rules, now, ); maybeProcHunterGoForTheThroatInWork( work, source.actorId, hunterTalentOwner?.talentRanks ?? null, attackKind, ability?.id ?? source.abilityId ?? "", combatResult.critical, combatResult.landed, periodic, now, ); const soulBrandLearnedPassives = sourceClassId === "rom-warlock" && sourceSecondaryClassId === "rom-mage" ? learnedRomCoaPassives( sourceClassId, sourceSecondaryClassId, sourcePartyMember?.level ?? work.progression.level, sourcePartyMember?.talentRanks ?? work.talentRanks, ) : []; const soulBrandEffects = romWarlockMageSoulBrandEffects({ classId: sourceClassId, secondaryClassId: sourceSecondaryClassId ?? "", learnedPassiveSpellIds: soulBrandLearnedPassives, }, { school, damageLanded: combatResult.landed && result.amount > 0, periodic, }); if (soulBrandEffects) { for (const effect of soulBrandEffects) { if (effect.kind === "apply-aura") { work.auras = [...applyCombatAura(work.auras, { definition: effect.aura, sourceId: source.actorId, targetId: id, now, }).auras]; } else if (effect.kind === "resource" && effect.resource === "psi") { grantDamageResourceInWork(work, { onDamageResource: { resource: "psi", amount: effect.amount }, }, result.amount, source.actorId, ability?.id ?? source.abilityId ?? `${ROM_WARLOCK_MAGE_SOUL_BRAND.passiveSpellId}`, now); } } } const deathKnightBloodyVengeanceOwner = sourceClassId === "death-knight" ? deathKnightTalentOwnerForActor(work, source.actorId) : null; const deathKnightBloodyVengeanceRank = deathKnightBloodyVengeanceOwner?.talentRanks[ WOW335_DEATH_KNIGHT_BLOODY_VENGEANCE_TALENT_ID ] ?? 0; if (result.amount > 0 && wow335DeathKnightBloodyVengeanceShouldTrigger( deathKnightBloodyVengeanceRank, combatResult.critical, )) { const definition = wow335DeathKnightBloodyVengeanceAuraDefinition(deathKnightBloodyVengeanceRank); if (definition) { work.auras = [...applyCombatAura(work.auras, { definition, sourceId: source.actorId, targetId: source.actorId, now, }).auras]; } } const paladinVengeancePlan = wow335PaladinVengeanceProcPlan(paladinVengeanceRank, { casterClassId: sourceClassId, source: rules.isAutoAttack ? "weapon-swing" : attackKind === "spell" ? "spell" : "ability", critical: combatResult.critical, damageDealt: combatResult.landed ? result.amount : 0, currentStacks: paladinVengeanceAura?.stacks ?? 0, }); if (paladinVengeancePlan) { const definition = wow335PaladinVengeanceAuraDefinition(paladinVengeanceRank); if (definition) { work.auras = [...applyCombatAura(work.auras, { definition, sourceId: source.actorId, targetId: source.actorId, // `add` consumes an increment, while the plan exposes the resulting capped total. stacks: 1, now, }).auras]; } } const deathKnightDesolationOwner = sourceClassId === "death-knight" && ability?.source === "wow335-progression" && ability.id === WOW335_DEATH_KNIGHT_BLOOD_STRIKE_ABILITY_ID ? deathKnightTalentOwnerForActor(work, source.actorId) : null; const deathKnightDesolationRank = deathKnightDesolationOwner?.talentRanks[ WOW335_DEATH_KNIGHT_DESOLATION_TALENT_ID ] ?? 0; if (wow335DeathKnightDesolationShouldTrigger( deathKnightDesolationRank, ability?.id ?? "", combatResult.landed, )) { const definition = wow335DeathKnightDesolationAuraDefinition(deathKnightDesolationRank); if (definition) { work.auras = [...applyCombatAura(work.auras, { definition, sourceId: source.actorId, targetId: source.actorId, now, }).auras]; } } const mageWintersChillOwner = sourceClassId === "mage" && attackKind === "spell" && !periodic && ability ? wow335MageTalentOwnerForActor(work, source.actorId) : null; if (mageWintersChillOwner && combatResult.landed && result.amount > 0 && ability) { const rank = mageTalentRank(mageWintersChillOwner.talentRanks, "Winter's Chill", 3); const proc = wow335MageWintersChillShouldProc(rank, { abilityName: ability.name, attackKind, school, periodic, landedDamage: result.amount, }, stableCombatUnit(`${seed}:wow335-mage-winters-chill`)); if (proc) { work.auras = [...applyCombatAura(work.auras, { definition: WOW335_MAGE_WINTERS_CHILL_AURA, sourceId: source.actorId, targetId: id, now, }).auras]; } } if ((improvedVictoryRushReadyAura || victoryRushKillReadyAura) && combatResult.landed) { const consumedVictoryRushReadyAuraIds = new Set([ improvedVictoryRushReadyAura?.instanceId, victoryRushKillReadyAura?.instanceId, ].filter((instanceId): instanceId is string => Boolean(instanceId))); work.auras = work.auras.filter((aura) => !consumedVictoryRushReadyAuraIds.has(aura.instanceId)); } const bloodCakedStrike = maybeProcDeathKnightBloodCakedBladeInWork( work, id, amount, now, source, attackKind, seed, periodic, rules, combatResult, weaponSpeedMs, ); if (bloodCakedStrike) { result = { ...result, killed: result.killed || bloodCakedStrike.killed, xpAwarded: result.xpAwarded + bloodCakedStrike.xpAwarded, levelsGained: result.levelsGained + bloodCakedStrike.levelsGained, }; } if (result.killed && mob.xpReward > 0 && sourceClassId === "warrior") { work.auras = [...applyCombatAura(work.auras, { definition: wow335WarriorVictoryRushKillAuraDefinition(), sourceId: source.actorId, targetId: source.actorId, now, }).auras]; } if (classId === "death-knight" && source.actorId === PLAYER_AGGRO_ID && attackKind === "melee" && !periodic && combatResult.landed) { const scentRank = work.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0; const hitKey = `${source.actorId}:${id}:${seed}`; const scentHitKeys = work.scentOfBloodHitKeys ?? (work.scentOfBloodHitKeys = new Set()); if (scentRank > 0 && !scentHitKeys.has(hitKey)) { scentHitKeys.add(hitKey); const scentOfBlood = wow335DeathKnightScentOfBloodOnMeleeHit( work.auras, source.actorId, scentRank, true, now, ); if (scentOfBlood.runicPowerGain > 0) { work.auras = [...scentOfBlood.auras]; const currentRunicPower = work.resourcePools["runic-power"] ?? work.resource; const maximumRunicPower = work.maxResourcePools["runic-power"] ?? work.maxResource; const restored = Math.max(0, Math.min( scentOfBlood.runicPowerGain, maximumRunicPower - currentRunicPower, )); work.resourcePools["runic-power"] = currentRunicPower + restored; work.resource = currentRunicPower + restored; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: source.actorId, targetActorId: source.actorId, abilityId: ability?.id ?? source.abilityId ?? WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG, rawAmount: scentOfBlood.runicPowerGain, effectiveAmount: restored, overheal: scentOfBlood.runicPowerGain - restored, absorbed: 0, critical: false, }); } } } maybeProcRomMageMeditativeCurrentInWork( work, ability, source.actorId, attackKind, periodic, combatResult.landed, now, ); maybeProcRomMageKnightLightOfDayInWork( work, ability, source.actorId, attackKind, periodic, combatResult.landed, now, ); maybeProcRomScoutPriestDrumsOfWarInWork( work, ability, source.actorId, attackKind, periodic, combatResult.landed, now, ); if (!periodic && combatResult.landed && ability?.source === "runewaker" && ability.classId === "rom-druid" && ability.secondaryClassId === "rom-warrior" && ROM_WARRIOR_BRIAR_TECHNIQUE_TRIGGER_SPELL_IDS.has(ability.dbcSpellId) && activeSourceAuras.some((aura) => aura.definition.utilityTags?.includes(ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG))) { work.auras = [...applyCombatAura(work.auras, { definition: ROM_WARRIOR_BRIAR_TECHNIQUE_AURA, sourceId: source.actorId, targetId: source.actorId, now, }).auras]; } if (reverberationAura && combatResult.landed && result.amount > 0) { work.auras = work.auras.filter((aura) => aura.instanceId !== reverberationAura.instanceId); } const impactAura = classId === "mage" && source.actorId === PLAYER_AGGRO_ID && !periodic && attackKind === "spell" && ability?.name === "Fire Blast" ? work.auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(WOW335_MAGE_IMPACT_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; if (impactAura && combatResult.landed && result.amount > 0) { const target = work.mobs[id]; if (target) { work.mobs[id] = applyStatus(target, { kind: "status", status: "stun", durationMs: 3_000, }, ability!.id, now, PLAYER_AGGRO_ID); } work.auras = work.auras.filter((aura) => aura.instanceId !== impactAura.instanceId); } const brainFreezeChannelDamage = ability?.name === "Blizzard" || ability?.name === "Frozen Orb"; if (classId === "mage" && source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" && combatResult.landed && result.amount > 0 && ability && WOW335_MAGE_BRAIN_FREEZE_TRIGGER_SPELLS.has(ability.name) && (!periodic || brainFreezeChannelDamage)) { const brainFreezeRank = mageTalentRank(work.talentRanks, "Brain Freeze", 3); const brainFreezeChance = [0, 0.05, 0.10, 0.15][brainFreezeRank] ?? 0; if (brainFreezeChance > 0 && stableCombatUnit(`${seed}:mage-brain-freeze`) < brainFreezeChance) { work.auras = [...applyCombatAura(work.auras, { definition: WOW335_MAGE_BRAIN_FREEZE_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } if (classId === "mage" && source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" && combatResult.landed && ability && !periodic) { maybeProcMageMissileBarrage(work, ability.name, seed, now); } const frostbiteRank = classId === "mage" && source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" && school === "frost" ? Math.min(3, Math.max(0, Math.trunc(work.talentRanks["talent-38"] ?? 0))) : 0; if (frostbiteRank > 0 && combatResult.landed && result.amount > 0 && stableCombatUnit(`${seed}:mage-frostbite`) < frostbiteRank * 0.05) { const target = work.mobs[id]; if (target) { work.mobs[id] = applyStatus(target, { kind: "status", status: "freeze", durationMs: 4_000, }, ability?.id ?? "wow335-mage-frostbite", now, PLAYER_AGGRO_ID); } } if (classId === "mage" && source.actorId === PLAYER_AGGRO_ID && !periodic && attackKind === "spell" && combatResult.landed && result.amount > 0 && ability) { const arcaneConcentrationRank = mageTalentRank(work.talentRanks, "Arcane Concentration", 3); const arcaneConcentrationChance = [0, 0.05, 0.10, 0.15][arcaneConcentrationRank] ?? 0; if (arcaneConcentrationChance > 0 && stableCombatUnit(`${seed}:mage-arcane-concentration`) < arcaneConcentrationChance) { work.auras = [...applyCombatAura(work.auras, { definition: WOW335_MAGE_ARCANE_CONCENTRATION_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; const arcanePotencyRank = mageTalentRank(work.talentRanks, "Arcane Potency", 2); if (wow335MageArcanePotencyCanProc(arcanePotencyRank, "arcane-concentration-clearcasting")) { work.auras = [...applyCombatAura(work.auras, { definition: WOW335_MAGE_ARCANE_POTENCY_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } // Only the rank-3 active row links Impact to child 64343. Its active // tooltip gives a 4% damaging-spell proc; it does not require a critical. const impactRank = mageTalentRank(work.talentRanks, "Impact", 3); if (impactRank >= 3 && school !== "physical" && stableCombatUnit(`${seed}:mage-impact`) < 0.04) { work.auras = [...applyCombatAura(work.auras, { definition: WOW335_MAGE_IMPACT_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } if (ability.name === "Scorch" && school === "fire") { const rank = mageTalentRank(work.talentRanks, "Improved Scorch", 3); if (rank > 0 && stableCombatUnit(`${seed}:mage-improved-scorch`) < rank / 3) { work.auras = [...applyCombatAura(work.auras, { definition: WOW335_MAGE_IMPROVED_SCORCH_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: id, now, }).auras]; } } if (school === "frost" && !["Blizzard", "Ice Lance"].includes(ability.name)) { const rank = mageTalentRank(work.talentRanks, "Fingers of Frost", 2); const chance = rank === 1 ? 0.07 : rank >= 2 ? 0.15 : 0; if (chance > 0 && stableCombatUnit(`${seed}:mage-fingers-of-frost`) < chance) { work.auras = [...applyCombatAura(work.auras, { definition: WOW335_MAGE_FINGERS_OF_FROST_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } if (combatResult.critical && MAGE_HOT_STREAK_CRITICAL_SPELLS.has(ability.name) && previousEligibleHotStreakCritical(work.combatEvents, source.actorId, ability, now)) { const rank = mageTalentRank(work.talentRanks, "Hot Streak", 3); if (rank > 0 && stableCombatUnit(`${seed}:mage-hot-streak`) < rank / 3) { work.auras = [...applyCombatAura(work.auras, { definition: WOW335_MAGE_HOT_STREAK_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } } const mageTalentOwner = wow335MageTalentOwnerForActor(work, source.actorId); if (mageTalentOwner && !periodic && attackKind === "spell" && combatResult.critical && school === "fire" && result.amount > 0 && ability) { const rank = mageTalentRank(mageTalentOwner.talentRanks, "Ignite", 5); if (rank > 0) { // Keep the existing local DBC burn payload. The talent owner is resolved // per source so one caster's ranks cannot apply Ignite for another. const totalBurn = Math.round(result.amount * rank * 0.08); const firstTick = Math.ceil(totalBurn / 2); const secondTick = totalBurn - firstTick; const makeIgniteTick = (amount: number, tickAt: number): TimedCombatEffect => ({ ...timedEffect("dot", ability.id, source.actorId, id, { coefficient: 0, fixedAmount: amount, }, 1, 2_000, now, "wow335-mage-ignite", "fire", mageTalentOwner.spellPenetration), nextTickAt: tickAt, }); work.effects = [ ...work.effects, makeIgniteTick(firstTick, now + 2_000), makeIgniteTick(secondTick, now + 4_000), ]; } } maybeProcWarlockDamageTalentsInWork( work, ability, source.actorId, id, result, attackKind, periodic, seed, now, ); if (ability?.source === "runewaker" && combatResult.critical && result.amount > 0) { const passiveTags = new Set(aurasForEntity(work.auras, source.actorId) .flatMap((aura) => aura.definition.utilityTags ?? [])); for (const rider of romMageWarlockCriticalEffects(ability, passiveTags)) { if (rider.kind !== "apply-aura") continue; work.auras = [...applyCombatAura(work.auras, { definition: auraDefinitionForAbility(rider.aura, ability), sourceId: source.actorId, targetId: id, now, }).auras]; } } if (combustion && combatResult.landed && result.amount > 0) { const criticalHits = (combustion.combustionCriticalHits ?? 0) + Number(combatResult.critical); work.auras = work.auras.flatMap((aura) => aura.instanceId !== combustion.instanceId ? [aura] : criticalHits >= 3 ? [] : [{ ...aura, stacks: Math.min(aura.definition.maxStacks ?? 100, aura.stacks + 1), combustionCriticalHits: criticalHits, }]); } if (source.actorId === PLAYER_AGGRO_ID && rules.isAutoAttack === true && !periodic && combatResult.landed) { work.auras = [...applyCoaGuardianVanguardEvent(work.auras, { id: PLAYER_AURA_ENTITY_ID, classId: work.classId, talentRanks: work.talentRanks, }, "auto-attack-landed", now)]; } const trigger = combatResult.outcome === "crit" ? "crit" : combatResult.outcome === "dodge" ? "dodge" : combatResult.outcome === "parry" ? "parry" : combatResult.outcome === "block" ? "block" : combatResult.landed ? "hit" : null; if (trigger) { evaluateProcsInWork(work, { trigger, subjectId: trigger === "dodge" || trigger === "parry" || trigger === "block" ? id : source.actorId, otherId: trigger === "dodge" || trigger === "parry" || trigger === "block" ? source.actorId : id, abilityId: ability?.id, amount: result.amount, school, attackKind, isAutoAttack: rules.isAutoAttack === true && !periodic, rollSeed: seed, }, now); } maybeProcPriestDamageTalentsInWork( work, ability, source.actorId, school, combatResult.critical, combatResult.landed, periodic, seed, now, ); if (classId === "druid" && source.actorId === PLAYER_AGGRO_ID) { maybeProcDruidPrimalFury( work, source.actorId, periodic, combatResult.critical, combatResult.landed, seed, now, ); } if (source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" && !periodic && combatResult.landed && result.amount > 0) { maybeProcShamanismAfterDamage(work, ability, seed, now); } if (classId === "warrior" && source.actorId === PLAYER_AGGRO_ID && attackKind === "melee" && !periodic && combatResult.landed && result.amount > 0) { maybeProcWarriorSecondWindInWork(work, seed, now); } if (classId === "shaman" && source.actorId === PLAYER_AGGRO_ID && attackKind === "melee" && !periodic && combatResult.landed && result.amount > 0) { maybeProcShamanMaelstromWeaponInWork(work, seed, now, weaponSpeedMs); } if (source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" && !periodic && combatResult.landed && result.amount > 0) { maybeProcShamanElementalFocusInWork(work, ability, school, combatResult.critical, periodic, seed, now); } maybeProcShamanElementalDevastationInWork( work, source.actorId, ability, attackKind, school, combatResult.critical, combatResult.landed, periodic, now, ); if (source.actorId === PLAYER_AGGRO_ID && !periodic && combatResult.landed && result.amount > 0) { maybeProcShamanReverberationInWork(work, ability, result.amount, periodic, seed, now); } if (source.actorId === PLAYER_AGGRO_ID && !periodic && combatResult.landed) { if (classId === "druid" && attackKind === "spell" && ability && result.amount > 0) { maybeProcDruidNatureGrace(work, ability, combatResult.critical, `${seed}:druid-nature-grace`, now); maybeProcDruidEclipse(work, ability, `${seed}:druid-eclipse`, now); } if (classId === "druid" && result.amount > 0 && (attackKind === "spell" || (attackKind === "melee" && rules.isAutoAttack))) { maybeProcDruidOmenOfClarity(work, `${seed}:druid-omen-of-clarity`, now); } } if ( combatResult.landed && result.amount > 0 && attackKind === "melee" && !periodic && !["Envenom", "Instant Poison", "Deadly Poison"].includes(ability?.name ?? "") ) { // Rogue main- and off-hand auto attacks now resolve as separate landed // hits, so each hand applies its own poison once. Other classes retain the // legacy single-event dual-wield behavior, and Mutilate still represents // two weapon strikes in one ability event. const hitCount = ability?.name === "Mutilate" || (rules.isAutoAttack && rules.isDualWielding && work.classId !== "rogue") ? 2 : 1; applyRogueWeaponPoisonsInWork( work, source.actorId, id, ability?.id ?? source.abilityId ?? "melee-hit", hitCount, now, ); } if (rules.isAutoAttack && !periodic && classId === "rom-rogue") { applyRomRoguePoisonOnNormalAttackInWork( work, source.actorId, id, attackKind, combatResult.landed, seed, now, ); } if (result.killed) { evaluateProcsInWork(work, { trigger: "kill", subjectId: source.actorId, otherId: id, abilityId: ability?.id, amount: result.amount, school, attackKind, }, now); } if (classId === "paladin" && source.actorId === PLAYER_AGGRO_ID && attackKind === "melee" && rules.isAutoAttack && !periodic && combatResult.landed && result.amount > 0 && work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(PALADIN_RECKONING_TAG) && (aura.expiresAt === null || aura.expiresAt > now) && aura.stacks > 0)) { work.auras = [...consumePaladinReckoningCharge(work.auras, now)]; const extraAttack = resolveAndDamageMobInWork( classId, work, id, amount, now, source, attackKind, school, `${seed}:paladin-reckoning-extra-attack`, undefined, false, { ...rules, isAutoAttack: false }, criticalChanceBonusPercent, ); return { amount: result.amount + extraAttack.amount, killed: result.killed || extraAttack.killed, xpAwarded: result.xpAwarded + extraAttack.xpAwarded, levelsGained: result.levelsGained + extraAttack.levelsGained, }; } return result; } function applyStatus( mob: MobCombatState, effect: Extract, abilityId: string, now: number, sourceActorId?: string, ): MobCombatState { if (mob.dead) return mob; if (mob.mechanicImmunities.includes(effect.status as AzerothCoreMechanic)) return mob; const durationFromTargetSpecificAbility = effect.durationMs; const baseDurationMs = mob.boss && effect.status === "stun" ? Math.min(3000, durationFromTargetSpecificAbility) : durationFromTargetSpecificAbility; const diminishingStatuses = new Set([ "stun", "fear", "root", "sleep", "blind", "freeze", "charm", "confuse", "knockdown", ]); const previousDiminishing = mob.controlDiminishingReturns?.[effect.status]; const applications = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status) && previousDiminishing?.resetAt && previousDiminishing.resetAt > now ? previousDiminishing.applications : 0; const multiplier = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status) ? [1, 0.5, 0.25, 0][Math.min(3, applications)] : 1; const durationMs = Math.round(baseDurationMs * multiplier); const controlDiminishingReturns = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status) ? { ...mob.controlDiminishingReturns, [effect.status]: { applications: Math.min(3, applications + 1), resetAt: now + (mob.characterDefender ? durationMs : 0) + 15_000, }, } : mob.controlDiminishingReturns; if (effect.status === "interrupt") { const cast = mob.activeCast; if (!cast?.interruptible) return mob; const attack = mob.attacks.find((entry) => entry.id === cast.attackId); if (attack) emitEnemyAttackPresentation(mob, attack, "cast-cancel", now, getMobPosition(mob.id) ?? cast.sourcePosition, cast.targetActorId, cast.targetPosition); return { ...mob, activeCast: null, nextAttackAt: now, schoolLockedUntil: { ...mob.schoolLockedUntil, [cast.school]: Math.max(mob.schoolLockedUntil[cast.school] ?? 0, now + durationMs), }, }; } if (durationMs <= 0) return { ...mob, controlDiminishingReturns }; const status: MobStatus = { kind: effect.status, sourceAbilityId: abilityId, ...(sourceActorId ? { sourceActorId } : {}), endsAt: now + durationMs, ...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }), ...(effect.breakOnDamage === undefined ? {} : { breakOnDamage: effect.breakOnDamage }), }; const incapacitated = [ "stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse", ].includes(effect.status); const cancelsActiveCast = Boolean(mob.activeCast && ( incapacitated || (effect.status === "silence" && mob.activeCast!.school !== "physical") || (effect.status === "disarm" && mob.activeCast!.school === "physical") )); if (cancelsActiveCast && mob.activeCast) { const cast = mob.activeCast; const attack = mob.attacks.find((entry) => entry.id === cast.attackId); if (attack) emitEnemyAttackPresentation(mob, attack, "cast-cancel", now, getMobPosition(mob.id) ?? cast.sourcePosition, cast.targetActorId, cast.targetPosition); } return { ...mob, ...(cancelsActiveCast ? { activeCast: null, nextAttackAt: now } : {}), controlDiminishingReturns, statuses: [...mob.statuses.filter((entry) => entry.kind !== effect.status), status], }; } function executeTriggeredSpellInWork( work: MutableCastWork, spellId: number, sourceId: string, targetId: string | null, now: number, depth: number, allowCoaHidden = false, originatingAbility?: AbilityDefinition, ): void { if (depth > 1) return; if (spellId === 29703 && (!isWow335ShieldBashDazeParent(originatingAbility) || !targetId || !wow335ShieldBashDazeTargetEligible(work.mobs[targetId]))) return; const catalogAbility = triggeredAbilityBySpellId(spellId, allowCoaHidden) ?? work.abilities.find((candidate) => ( candidate.dbcSpellId === spellId || candidate.ranks?.some((rank) => rank.spellId === spellId) )) ?? null; if (!catalogAbility) return; const partySource = usePartyStore.getState().members.find((member) => member.id === sourceId); const sourceClassId = partySource?.classId ?? work.classId; const sourceLevel = partySource?.level ?? work.progression.level; const sourceGear = partySource?.gearStats ?? work.gearStats; const sourceRace = partySource?.raceId ?? work.raceId; const sourceSecondary = partySource?.secondaryClassId ?? work.secondaryClassId; const sourceGender = partySource?.gender ?? work.gender; const sourceRuleset = partySource?.statRuleset ?? work.statRuleset; const sourceModifiers = partySource ? talentModifiersForRanks(partySource.talentRanks, partySource.classId) : work.talentModifiers; const ability = abilityAtLevel(catalogAbility, sourceLevel); const source = playerThreatSource({ talentModifiers: work.talentModifiers }, ability.id, true); const sourceBaseStats = sourceId === PLAYER_AURA_ENTITY_ID || partySource ? playerCharacterStats( sourceClassId, sourceLevel, sourceGear, sourceRace, sourceSecondary, sourceModifiers, sourceGender, sourceRuleset, ) : null; const holyBlessingSpellPowerBonus = sourceBaseStats && sourceRuleset !== "rom" ? romPriestHolyBlessingStats( sourceBaseStats, aurasForEntity(work.auras, sourceId, now), ).spellPowerBonus : 0; const attackKind = abilityAttackKind(sourceClassId, ability); const school = abilityDamageSchool(sourceClassId, ability); const mobTargets = targetId && work.mobs[targetId] && !work.mobs[targetId].dead ? [targetId] : []; for (const effect of ability.effects) { if (effect.kind === "damage") { const amount = talentAdjustedAmount( equipmentAdjustedCombatAmount( effect, sourceLevel, sourceGear, sourceClassId, ability, holyBlessingSpellPowerBonus, undefined, sourceRace, sourceGender, sourceRuleset, sourceSecondary, sourceBaseStats ? auraAdjustedCharacterStats(sourceBaseStats, work.auras, sourceId, now).romNative : undefined, undefined, activeEnemyDisarmChannels(work.auras, sourceId, now), ), sourceModifiers, "damage-percent", ability, ); for (const mobId of mobTargets) { const effectSchool = effect.school ?? school; const result = resolveAndDamageMobInWork( sourceClassId, work, mobId, amount, now, source, effectSchool === "physical" ? attackKind : "spell", effectSchool, `${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`, ability, ); grantDamageResourceInWork(work, effect, result.amount, sourceId, ability.id, now); } } else if (effect.kind === "heal") { const rawAmount = talentAdjustedAmount( equipmentAdjustedCombatAmount( effect, sourceLevel, sourceGear, sourceClassId, ability, holyBlessingSpellPowerBonus, undefined, sourceRace, sourceGender, sourceRuleset, sourceSecondary, sourceBaseStats ? auraAdjustedCharacterStats(sourceBaseStats, work.auras, sourceId, now).romNative : undefined, ) + Math.round((sourceId === PLAYER_AURA_ENTITY_ID ? work.maxHealth : work.actors[sourceId]?.maxHealth ?? 0) * (effect.percentMaxHealth ?? 0)), sourceModifiers, "healing-percent", ability, ); const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors, id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id), [targetId && !work.mobs[targetId] ? targetId : sourceId], combatHasLineOfSight, now); for (const recipient of recipients) { const amount = Math.round(resolveAuraHealingValue( resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"), aurasForEntity(work.auras, recipient), "taken", )); if (recipient === PLAYER_AURA_ENTITY_ID) { work.totalHealing += healPlayerInWork(work, amount); } else { const healed = healCompanionActorInWork(work, recipient, amount); work.totalHealing += healed; } } } else if (effect.kind === "status") { for (const mobId of mobTargets) { work.mobs[mobId] = applyStatus(work.mobs[mobId], effect, ability.id, now); } } else if (effect.kind === "interrupt") { for (const mobId of mobTargets) { work.mobs[mobId] = applyStatus(work.mobs[mobId], { kind: "status", status: "interrupt", durationMs: effect.lockoutMs, }, ability.id, now); } } else if (effect.kind === "apply-aura") { const recipient = effect.recipient === "caster" ? sourceId : effect.recipient === "pet" ? activeHunterPetId(work.summons, now) : targetId ?? sourceId; if (!recipient) continue; const previousAuras = work.auras; work.auras = [...applyCombatAura(previousAuras, { definition: auraDefinitionForAbility(effect.aura, ability), sourceId, targetId: recipient, now, }).auras]; applyAuraHealthTransitionInWork(work, recipient, previousAuras, work.auras); if (effect.control && work.mobs[recipient]) { work.mobs[recipient] = applyStatus(work.mobs[recipient], { kind: "status", status: effect.control.kind as MobStatusKind, durationMs: effect.aura.durationMs ?? 1_000, ...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }), }, `aura:${effect.aura.id}`, now); } } else if (effect.kind === "resource") { const resource = effect.resource ?? resourceProfileForClass(work.classId).type; const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource); const amount = effect.percentage ? maximum * effect.amount / 100 : effect.amount; work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + amount, 0, maximum); if (resource === resourceProfileForClass(work.classId).type) { work.resource = work.resourcePools[resource] ?? work.resource; } } else if (effect.kind === "dot") { for (const mobId of mobTargets) { work.effects = work.effects.filter((entry) => !( entry.kind === "dot" && entry.targetId === mobId && (effect.tag ? entry.tag === effect.tag : entry.sourceAbilityId === ability.id) )); work.effects.push(timedEffect( "dot", ability.id, source.actorId, mobId, effect, effect.ticks, effect.intervalMs, now, effect.tag, school, work.gearStats.spellPenetration, )); } } else if (effect.kind === "hot") { const fallbackRecipients = effect.recipient === "pet" ? [activeHunterPetId(work.summons, now)].filter((id): id is string => id !== null) : [targetId && !work.mobs[targetId] ? targetId : sourceId]; const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors, id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id), fallbackRecipients, combatHasLineOfSight, now); for (const recipient of recipients) { work.effects.push(timedEffect( "hot", ability.id, source.actorId, recipient, effect, effect.ticks, effect.intervalMs, now, )); } } else if (effect.kind === "shield") { const amount = equipmentAdjustedCombatAmount( effect, sourceLevel, sourceGear, sourceClassId, ability, 0, undefined, sourceRace, sourceGender, sourceRuleset, sourceSecondary, ); const recipient = targetId && !work.mobs[targetId] ? targetId : PLAYER_AURA_ENTITY_ID; if (recipient === PLAYER_AURA_ENTITY_ID) { work.shield = Math.max(work.shield, amount); work.shieldExpiresAt = now + effect.durationMs; } else { work.auras = [...applyCombatAura(work.auras, { definition: { id: `coa-triggered-shield:${spellId}`, name: ability.name, description: ability.description, icon: ability.icon, disposition: "buff", durationMs: effect.durationMs, absorbs: [{ id: `coa-triggered-shield:${spellId}:pool`, amount }], }, sourceId, targetId: recipient, now, }).auras]; } } else if (effect.kind === "summon") { summonSequence += 1; work.summons.push({ id: `player-triggered-summon-${summonSequence}`, sourceAbilityId: ability.id, creatureId: effect.creatureId, targetId, expiresAt: now + effect.durationMs, kind: /totem/i.test(ability.name) ? "totem" : "summon", name: ability.name, ownerActorId: sourceId, health: effect.healingPulse ? 5 : Math.max(1, Math.round(work.maxHealth * 0.3)), maxHealth: effect.healingPulse ? 5 : Math.max(1, Math.round(work.maxHealth * 0.3)), position: getMobPosition(targetId ?? "") ?? [0, 0, 0], nextActionAt: now + (effect.healingPulse?.intervalMs ?? 1_000), pulseIntervalMs: effect.healingPulse?.intervalMs ?? (ability.name === "Shadowfiend" ? 1_500 : /totem/i.test(ability.name) ? 2_000 : 2_200), attackDamage: effect.healingPulse ? 0 : Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))), ...(effect.healingPulse ? { healingPulse: effect.healingPulse } : {}), ...initialSummonCombatRuntime(), }); } else if (effect.kind === "resource-drain") { const resource = effect.resource ?? resourceProfileForClass(work.classId).type; const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource); const amount = effect.percentage ? maximum * effect.amount / 100 : effect.amount; if (effect.recipient === "caster") { work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - amount); } else if (mobTargets.length && !effect.burn) { const resourceBefore = work.resourcePools[resource] ?? 0; work.resourcePools[resource] = clamp(resourceBefore + amount, 0, maximum); } else if (!mobTargets.length) { work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - amount); } if (resource === resourceProfileForClass(work.classId).type) { work.resource = work.resourcePools[resource] ?? work.resource; } } else if (effect.kind === "pull") { const playerPosition = useCombatStore.getState().playerPosition; for (const mobId of mobTargets) { const position = getMobPosition(mobId); if (!position || effect.toward !== "caster") continue; const dx = position[0] - playerPosition[0]; const dz = position[2] - playerPosition[2]; const length = Math.max(0.001, Math.hypot(dx, dz)); setMobPosition(mobId, [ playerPosition[0] + dx / length * 1.5, position[1], playerPosition[2] + dz / length * 1.5, ]); } } else if (effect.kind === "dispel") { const recipient = targetId ?? sourceId; const targeted = work.auras.filter((aura) => aura.targetId === recipient); const untouched = work.auras.filter((aura) => aura.targetId !== recipient); const result = dispelCombatAuras(targeted, { relationship: effect.relationship === "target" ? work.mobs[recipient] ? "hostile" : "friendly" : effect.relationship, categories: effect.categories, maxCount: effect.maxCount, }); work.auras = [...untouched, ...result.auras]; } else if (effect.kind === "execute") { for (const mobId of mobTargets) { const mob = work.mobs[mobId]; if (mob && !mob.boss) damageMobInWork( work.classId, work, mobId, Math.max(mob.health, mob.maxHealth * effect.coefficient), now, source, school, ); } } else if (effect.kind === "threat") { for (const mobId of mobTargets) { const mob = work.mobs[mobId]; if (!mob) continue; const current = mob.threatByActor[source.actorId] ?? 0; work.mobs[mobId] = { ...mob, threatByActor: { ...mob.threatByActor, [source.actorId]: effect.mode === "percent" ? current * Math.max(0, 1 + effect.amount / 100) : effect.mode === "redirect" ? 0 : Math.max(0, current + effect.amount), }, }; } } else if (effect.kind === "cooldown-reset") { const candidates = cooldownResetCandidates( work.abilities, ability.id, effect.abilityName, effect.excludeAbilityNames, ); for (const candidate of candidates) { if (effect.mode === "reset") work.cooldowns[candidate.id] = 0; else { const readyAt = work.cooldowns[candidate.id] ?? now; const reduction = effect.amountPercent !== undefined ? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, effect.amountPercent)) : effect.amountMs ?? 0; work.cooldowns[candidate.id] = Math.max(now, readyAt - reduction); } } } else if (effect.kind === "trigger-spell" && !effect.expanded && depth < 1 && effect.spellId !== spellId) { executeTriggeredSpellInWork(work, effect.spellId, sourceId, targetId, now, depth + 1, allowCoaHidden, ability); } } } function grantDamageResourceInWork( work: MutableCastWork, effect: AbilityDamageResource, damage: number, sourceActorId: string, abilityId: string, now: number, ): void { const gain = effect.onDamageResource; if (!gain || damage <= 0 || gain.amount <= 0) return; const player = sourceActorId === PLAYER_AURA_ENTITY_ID; const actor = player ? null : ensureCompanionActorInWork(work, sourceActorId) ?? work.actors[sourceActorId]; if (player ? work.health <= 0 : !actor?.alive) return; const pool = actor?.resources[gain.resource]; const maximum = player ? work.maxResourcePools[gain.resource] ?? defaultResourceMaximum(gain.resource) : pool?.maximum ?? defaultResourceMaximum(gain.resource); const current = player ? work.resourcePools[gain.resource] ?? 0 : pool?.current ?? 0; const auras = aurasForEntity(work.auras, sourceActorId); const requested = Math.max(0, resolveAuraResourceValue( resolveAuraResourceValue(gain.amount, auras, gain.resource, "generation"), auras, "power", "generation", )); const restored = Math.max(0, Math.min(requested, maximum - current)); if (player) { work.resourcePools[gain.resource] = current + restored; if (gain.resource === resourceProfileForClass(work.classId).type) work.resource = current + restored; } else if (actor) { const next = { ...actor, resources: { ...actor.resources, [gain.resource]: { ...(pool ?? normalizeActorResourcePool(gain.resource, current, maximum)), current: current + restored } } }; work.actors[sourceActorId] = next; mirrorCompanionActor(next); } recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId, targetActorId: sourceActorId, abilityId, rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false }); } function withoutUnlearnedTalentBindings( bindings: ActionBindings, abilities: readonly AbilityDefinition[], talentRanks: TalentRanks, ): ActionBindings { const unlearned = new Set(abilities .filter((ability) => abilityTalentNodeId(ability) && !(learnedAbilityRank(ability, talentRanks) ?? 0)) .map((ability) => ability.id)); return Object.fromEntries(Object.entries(bindings).map(([id, abilityId]) => [ id, abilityId && unlearned.has(abilityId) ? null : abilityId, ])) as ActionBindings; } function timedEffect( kind: "dot" | "hot", abilityId: string, sourceActorId: AggroActorId, targetId: string | null, scaling: AbilityAmountScaling & AbilityDamageResource & { readonly coefficient: number; readonly startsImmediately?: boolean; readonly percentMaxHealth?: number; readonly fixedAmount?: number; }, ticks: number, intervalMs: number, now: number, tag?: string, damageSchool?: DamageSchool, spellPenetration?: number, criticalChanceBonusPercent?: number, ): TimedCombatEffect { effectSequence += 1; return { id: `${abilityId}:${effectSequence}`, kind, sourceAbilityId: abilityId, sourceActorId, targetId, coefficient: scaling.coefficient, ...(scaling.romNativeSource ? { romNativeSource: scaling.romNativeSource } : {}), ...(scaling.basePoints === undefined ? {} : { basePoints: scaling.basePoints }), ...(scaling.dieSides === undefined ? {} : { dieSides: scaling.dieSides }), ...(scaling.pointsPerLevel === undefined ? {} : { pointsPerLevel: scaling.pointsPerLevel }), ...(scaling.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: scaling.coefficientPerLevel }), ...(scaling.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: scaling.bonusPowerCoefficient }), ...(scaling.attackPowerCoefficient === undefined ? {} : { attackPowerCoefficient: scaling.attackPowerCoefficient }), ...(scaling.rangedAttackPowerCoefficient === undefined ? {} : { rangedAttackPowerCoefficient: scaling.rangedAttackPowerCoefficient }), ...(scaling.spiritCoefficient === undefined ? {} : { spiritCoefficient: scaling.spiritCoefficient }), ...(scaling.sourceLevel === undefined ? {} : { sourceLevel: scaling.sourceLevel }), ...(scaling.maxScalingLevel === undefined ? {} : { maxScalingLevel: scaling.maxScalingLevel }), ...(scaling.percentMaxHealth === undefined ? {} : { percentMaxHealth: scaling.percentMaxHealth }), ...(scaling.fixedAmount === undefined ? {} : { fixedAmount: scaling.fixedAmount }), ...(scaling.onDamageResource ? { onDamageResource: scaling.onDamageResource } : {}), remainingTicks: ticks, intervalMs, nextTickAt: now + (scaling.startsImmediately ? 0 : intervalMs), ...(tag ? { tag } : {}), ...(damageSchool ? { damageSchool } : {}), ...(spellPenetration ? { spellPenetration } : {}), ...(criticalChanceBonusPercent ? { criticalChanceBonusPercent } : {}), }; } function accumulateLightwellDamageCancellation( effects: readonly TimedCombatEffect[], targetId: string, effectiveDamage: number, ): TimedCombatEffect[] { if (!Number.isFinite(effectiveDamage) || effectiveDamage <= 0) return [...effects]; return effects.flatMap((effect) => { const cancellation = effect.kind === "hot" ? effect.lightwellDamageCancellation : undefined; if (!cancellation || (effect.targetId ?? PLAYER_AURA_ENTITY_ID) !== targetId) return [effect]; const accumulatedDamage = cancellation.accumulatedDamage + effectiveDamage; if (accumulatedDamage >= cancellation.threshold) return []; return [{ ...effect, lightwellDamageCancellation: { ...cancellation, accumulatedDamage } }]; }); } const WARLOCK_SEED_PROFILE_BY_SPELL_ID: Readonly> = { 27243: { threshold: 1_044, explosionSpellId: 27_285 }, 47835: { threshold: 1_296, explosionSpellId: 47_833 }, 47836: { threshold: 1_518, explosionSpellId: 47_834 }, }; function warlockSeedProfileForAbility(ability: AbilityDefinition): { threshold: number; explosionSpellId: number; radius: number; accumulatedDamage: number; } | null { if (ability.name !== "Seed of Corruption") return null; const profile = WARLOCK_SEED_PROFILE_BY_SPELL_ID[ability.dbcSpellId]; return profile ? { ...profile, radius: 15, accumulatedDamage: 0 } : null; } function activeWarlockConflagrateDot( effects: readonly TimedCombatEffect[], targetId: string, sourceActorId: AggroActorId, 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; } function warlockCurseOfAgonyTickAmount(amount: number, tickNumber: number): number { if (tickNumber <= 4) return Math.floor(amount / 2); if (tickNumber <= 8) return amount; return Math.floor((amount + 1) * 1.5); } function detonateLivingBombInWork(work: MutableCastWork, effect: TimedCombatEffect, now: number): void { if (effect.kind !== "dot" || !effect.targetId) return; const source = abilityById(effect.sourceAbilityId); if (source?.name !== "Living Bomb") return; const center = getMobPosition(effect.targetId); if (!center) return; const sourceMember = usePartyStore.getState().members.find((member) => member.id === effect.sourceActorId); const ability = abilityAtLevel(source, sourceMember?.level ?? work.progression.level); const tickAmount = effect.fixedAmount ?? equipmentAdjustedCombatAmount( effect, sourceMember?.level ?? work.progression.level, sourceMember?.gearStats ?? work.gearStats, sourceMember?.classId ?? work.classId, ability, 0, undefined, sourceMember?.raceId ?? work.raceId, sourceMember?.gender ?? work.gender, sourceMember?.statRuleset ?? work.statRuleset, sourceMember?.secondaryClassId ?? work.secondaryClassId, ); const explosionAmount = Math.max(1, Math.round(tickAmount * 2)); for (const id of nearbyMobRuntimeIds(center, 10)) { if (work.mobs[id]?.dead) continue; damageMobInWork(work.classId, work, id, explosionAmount, now, threatSourceForActor(work, effect.sourceActorId, effect.sourceAbilityId, true), "fire", sourceMember?.gearStats.spellPenetration ?? work.gearStats.spellPenetration); } emitCombatPresentation({ occurredAt: now, phase: "impact", actorGroup: effect.sourceActorId === PLAYER_AGGRO_ID ? "player" : "party", sourceActorId: effect.sourceActorId, targetActorId: effect.targetId, abilityId: ability.id, sourceSpellId: ability.dbcSpellId, name: ability.name, source: presentationSourceForAbility(ability), school: "fire", delivery: "area", origin: center, targetPosition: center, }); } function detonateLivingBombFromStore(effect: TimedCombatEffect, now: number): void { if (!effect.targetId || abilityById(effect.sourceAbilityId)?.name !== "Living Bomb") return; const center = getMobPosition(effect.targetId); if (!center) return; const state = useCombatStore.getState(); const sourceMember = usePartyStore.getState().members.find((member) => member.id === effect.sourceActorId); const source = abilityById(effect.sourceAbilityId)!; const ability = abilityAtLevel(source, sourceMember?.level ?? state.level); const tickAmount = effect.fixedAmount ?? equipmentAdjustedCombatAmount(effect, sourceMember?.level ?? state.level, sourceMember?.gearStats ?? state.gearStats, sourceMember?.classId ?? state.classId, ability, 0, undefined, sourceMember?.raceId ?? state.raceId, sourceMember?.gender ?? state.gender, sourceMember?.statRuleset ?? state.statRuleset, sourceMember?.secondaryClassId ?? state.secondaryClassId); for (const id of nearbyMobRuntimeIds(center, 10)) { if (useCombatStore.getState().mobs[id]?.dead) continue; useCombatStore.getState().damageMob(id, Math.max(1, Math.round(tickAmount * 2)), now, threatSourceForActor(useCombatStore.getState(), effect.sourceActorId, effect.sourceAbilityId, true), "fire"); } emitCombatPresentation({ occurredAt: now, phase: "impact", actorGroup: effect.sourceActorId === PLAYER_AGGRO_ID ? "player" : "party", sourceActorId: effect.sourceActorId, targetActorId: effect.targetId, abilityId: ability.id, sourceSpellId: ability.dbcSpellId, name: ability.name, source: presentationSourceForAbility(ability), school: "fire", delivery: "area", origin: center, targetPosition: center, }); } interface RoguePoisonSourceStats { readonly classId: ClassId; readonly level: number; readonly attackPower: number; readonly spellPenetration: number; } function roguePoisonSourceStats( work: MutableCastWork, sourceActorId: AggroActorId, ): RoguePoisonSourceStats | null { if (sourceActorId === PLAYER_AGGRO_ID) { if (baseClassFor(work.classId) !== "rogue") return null; const stats = playerCharacterStats( work.classId, work.progression.level, work.gearStats, work.raceId, work.secondaryClassId ?? null, work.talentModifiers, work.gender, work.statRuleset, ); return { classId: work.classId, level: work.progression.level, attackPower: stats.attackPower, spellPenetration: work.gearStats.spellPenetration, }; } const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); if (!member || baseClassFor(member.classId) !== "rogue") return null; const stats = deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", ); return { classId: member.classId, level: member.level, attackPower: stats.attackPower, spellPenetration: member.gearStats.spellPenetration, }; } function replaceDeadlyPoisonEffect( work: MutableCastWork, sourceActorId: AggroActorId, targetId: string, stacks: number, now: number, refreshDuration: boolean, ): void { const ability = abilityById("rogue-deadly-poison"); const source = roguePoisonSourceStats(work, sourceActorId); const existing = work.effects.find((effect) => ( effect.kind === "dot" && effect.sourceActorId === sourceActorId && effect.targetId === targetId && effect.sourceAbilityId === "rogue-deadly-poison" )); work.effects = work.effects.filter((effect) => effect !== existing); if (!ability || !source || stacks <= 0) return; const amount = rogueDeadlyPoisonTickDamage(source.level, source.attackPower, stacks); if (!refreshDuration && existing) { work.effects.push({ ...existing, fixedAmount: amount }); return; } work.effects.push({ ...timedEffect( "dot", ability.id, sourceActorId, targetId, { coefficient: 0 }, 4, 3_000, now, ROGUE_DEADLY_POISON_DOSE_TAG, "nature", source.spellPenetration, ), fixedAmount: amount, }); } function applyRogueWeaponPoisonsInWork( work: MutableCastWork, sourceActorId: AggroActorId, targetId: string, sourceAbilityId: string, hitCount: number, now: number, ): RoguePoisonProcResult { const source = roguePoisonSourceStats(work, sourceActorId); const target = work.mobs[targetId]; if (!source || !target || target.dead) { return { instantProcs: 0, deadlyApplications: 0, deadlyStacks: 0, damage: 0 }; } const instantCoating = hasActiveAuraTag(work.auras, ROGUE_INSTANT_POISON_TAG, sourceActorId, now); const deadlyCoating = hasActiveAuraTag(work.auras, ROGUE_DEADLY_POISON_COATING_TAG, sourceActorId, now); const envenomBuff = hasActiveAuraTag(work.auras, ROGUE_ENVENOM_BUFF_TAG, sourceActorId, now); const isShiv = abilityById(sourceAbilityId)?.name === "Shiv"; const chances = roguePoisonApplicationChances(envenomBuff); let instantProcs = 0; let deadlyApplications = 0; let damage = 0; const applyInstant = (): void => { if (!instantCoating || work.mobs[targetId]?.dead) return; const result = damageMobInWork( work.classId, work, targetId, rogueInstantPoisonDamage(source.level, source.attackPower), now, threatSourceForActor(work, sourceActorId, "rogue-instant-poison", true), "nature", source.spellPenetration, ); if (result.amount <= 0) return; instantProcs += 1; damage += result.amount; }; for (let hitIndex = 0; hitIndex < Math.max(1, Math.floor(hitCount)); hitIndex += 1) { if (work.mobs[targetId]?.dead) break; if (!isShiv && instantCoating && stableCombatUnit( `${sourceActorId}:${targetId}:${sourceAbilityId}:${now}:${hitIndex}:instant`, ) < chances.instant) { applyInstant(); } if (!deadlyCoating || (!isShiv && stableCombatUnit( `${sourceActorId}:${targetId}:${sourceAbilityId}:${now}:${hitIndex}:deadly`, ) >= chances.deadly)) continue; const previousStacks = matchingAuraStacks( work.auras, ROGUE_DEADLY_POISON_DOSE_TAG, sourceActorId, targetId, now, ); const application = applyCombatAura(work.auras, { definition: ROGUE_DEADLY_POISON_AURA, sourceId: sourceActorId, targetId, now, }); work.auras = [...application.auras]; deadlyApplications += 1; recordCombatEventInWork(work, { occurredAt: now, kind: "aura", sourceActorId, targetActorId: targetId, abilityId: "rogue-deadly-poison", school: "nature", rawAmount: 0, effectiveAmount: application.aura.stacks, overheal: 0, absorbed: 0, critical: false, }); replaceDeadlyPoisonEffect(work, sourceActorId, targetId, application.aura.stacks, now, true); // At five doses, another Deadly Poison application triggers the poison on // the other weapon instead of creating a sixth dose. if (previousStacks >= 5) applyInstant(); } return { instantProcs, deadlyApplications, deadlyStacks: matchingAuraStacks( work.auras, ROGUE_DEADLY_POISON_DOSE_TAG, sourceActorId, targetId, now, ), damage, }; } function consumeRogueDeadlyPoisonInWork( work: MutableCastWork, sourceActorId: AggroActorId, targetId: string, maximumDoses: number, now: number, ): number { const index = work.auras.findIndex((aura) => ( aura.sourceId === sourceActorId && aura.targetId === targetId && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(ROGUE_DEADLY_POISON_DOSE_TAG) )); if (index < 0) return 0; const aura = work.auras[index]; const consumed = Math.min(aura.stacks, Math.max(0, Math.floor(maximumDoses))); const remaining = aura.stacks - consumed; if (remaining <= 0) work.auras.splice(index, 1); else work.auras[index] = { ...aura, stacks: remaining }; replaceDeadlyPoisonEffect(work, sourceActorId, targetId, remaining, now, false); return consumed; } function entityAbsorbAmount(auras: readonly ActiveAura[], entityId: string): number { return aurasForEntity(auras, entityId).reduce((total, aura) => ( total + aura.absorbs.reduce((sum, absorb) => sum + Math.max(0, absorb.remaining), 0) ), 0); } function ensureCompanionActorInWork(work: MutableCastWork, id: string): CombatActorState | null { const member = usePartyStore.getState().members.find((candidate) => candidate.id === id); if (!member) return null; const existing = work.actors[id]; const actor = companionActorFromMember(member, entityAbsorbAmount(work.auras, id), existing); work.actors[id] = actor; return actor; } function mirrorCompanionActor(actor: CombatActorState): void { if (actor.kind !== "companion") return; const member = usePartyStore.getState().members.find((candidate) => candidate.id === actor.id); if (!member) return; const mirrored = memberFromCompanionActor(member, actor); usePartyStore.getState().updateMemberCombat(actor.id, { health: mirrored.health, maxHealth: mirrored.maxHealth, resource: mirrored.resource, maxResource: mirrored.maxResource, resourcePools: mirrored.resourcePools, resourceMaximums: mirrored.resourceMaximums, lastResourceSpentAt: mirrored.lastResourceSpentAt, cooldowns: mirrored.cooldowns, globalCooldownEndsAt: mirrored.globalCooldownEndsAt, activeCast: mirrored.activeCast, activeCastTargetId: mirrored.activeCastTargetId, schoolLockedUntil: mirrored.schoolLockedUntil, runes: mirrored.runes, controlledUntil: mirrored.controlledUntil, status: mirrored.status, }); } function applyAuraHealthTransitionInWork( work: MutableCastWork, targetId: string, previousAuras: readonly ActiveAura[], nextAuras: readonly ActiveAura[], ): void { if (targetId === PLAYER_AURA_ENTITY_ID) { const nativeMana = nativeManaPoolMaximum( work.classId, work.progression.level, work.talentModifiers, work.gearStats, work.raceId, work.secondaryClassId, work.gender, work.statRuleset, nextAuras, targetId, ); if (nativeMana !== null) { const resized = resizeManaPool(work.resourcePools, work.maxResourcePools, nativeMana, false); work.resourcePools = resized.pools; work.maxResourcePools = resized.maximums; if (resourceProfileForClass(work.classId).type === "mana") { work.resource = resized.pools.mana ?? 0; work.maxResource = nativeMana; } const actor = work.actors[targetId]; if (actor) { const previous = actor.resources.mana; work.actors[targetId] = Object.freeze({ ...actor, resources: { ...actor.resources, mana: normalizeActorResourcePool( "mana", resized.pools.mana ?? 0, nativeMana, previous?.regenerationPerSecond ?? 0, previous?.lastSpentAt ?? 0, ), }, }); } } const previousMaximum = work.maxResourcePools.energy ?? defaultResourceMaximum("energy"); const nextMaximum = romRogueEnergyMaximumAfterAuraTransition( previousMaximum, previousAuras, nextAuras, targetId, ); if (nextMaximum !== previousMaximum) { const current = Math.min(nextMaximum, work.resourcePools.energy ?? 0); work.maxResourcePools = { ...work.maxResourcePools, energy: nextMaximum }; work.resourcePools = { ...work.resourcePools, energy: current }; if (resourceProfileForClass(work.classId).type === "energy") { work.maxResource = nextMaximum; work.resource = current; } const actor = work.actors[targetId]; const pool = actor?.resources.energy; if (actor && pool) work.actors[targetId] = { ...actor, resources: { ...actor.resources, energy: { ...pool, current, maximum: nextMaximum } }, }; } } else { const actor = ensureCompanionActorInWork(work, targetId); const pool = actor?.resources.energy; if (actor && pool) { const maximum = romRogueEnergyMaximumAfterAuraTransition( pool.maximum, previousAuras, nextAuras, targetId, ); if (maximum !== pool.maximum) { const nextActor = Object.freeze({ ...actor, resources: { ...actor.resources, energy: { ...pool, maximum, current: Math.min(maximum, pool.current) }, }, }); work.actors[targetId] = nextActor; mirrorCompanionActor(nextActor); } } } if (targetId === PLAYER_AURA_ENTITY_ID) { const maxHealth = auraAdjustedPlayerMaxHealth( work.classId, work.progression.level, work.talentModifiers, work.gearStats, work.raceId, work.secondaryClassId, nextAuras, work.gender, work.statRuleset, ); const delta = maxHealth - work.maxHealth; if (!delta) return; work.maxHealth = maxHealth; work.health = work.health <= 0 ? 0 : clamp(work.health + delta, 1, work.maxHealth); work.progression.maxHealth = work.maxHealth; work.progression.health = work.health; const actor = work.actors[targetId]; if (actor) work.actors[targetId] = { ...actor, health: work.health, maxHealth: work.maxHealth }; return; } const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetId); const actor = ensureCompanionActorInWork(work, targetId); if (!member || !actor) return; const nativeMana = nativeManaPoolMaximum( member.classId, member.level, talentModifiersForRanks(member.talentRanks, member.classId), member.gearStats, member.raceId, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", nextAuras, targetId, ); const manaPool = actor.resources.mana; if (nativeMana !== null) { const current = Math.min(nativeMana, manaPool?.current ?? member.resourcePools.mana ?? nativeMana); const nextActor = Object.freeze({ ...actor, resources: { ...actor.resources, mana: normalizeActorResourcePool( "mana", current, nativeMana, manaPool?.regenerationPerSecond ?? 0, manaPool?.lastSpentAt ?? 0, ), }, }); work.actors[targetId] = nextActor; mirrorCompanionActor(nextActor); } const base = deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", ); const delta = auraMaximumHealthBonus(base, nextAuras, targetId) - auraMaximumHealthBonus(base, previousAuras, targetId); if (!delta) return; const maxHealth = Math.max(1, actor.maxHealth + delta); const health = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, maxHealth); const nextActor = Object.freeze({ ...actor, health, maxHealth }); work.actors[targetId] = nextActor; mirrorCompanionActor(nextActor); } /** Ordinary healing never transitions a corpse back to a living actor. */ function healPlayerInWork(work: MutableCastWork, amount: number): number { const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0)); if (work.health <= 0 || requested <= 0) return 0; const healed = Math.min(requested, Math.max(0, work.maxHealth - work.health)); work.health += healed; work.progression.health = work.health; const actor = work.actors[PLAYER_AURA_ENTITY_ID]; if (actor) work.actors[PLAYER_AURA_ENTITY_ID] = { ...actor, health: work.health, alive: true }; return healed; } function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number { const actor = ensureCompanionActorInWork(work, id); const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0)); if (!actor || actor.health <= 0 || requested <= 0) return 0; const healed = Math.min(requested, actor.maxHealth - actor.health); if (healed <= 0) return 0; const next = Object.freeze({ ...actor, health: actor.health + healed, alive: true }); work.actors[id] = next; mirrorCompanionActor(next); return healed; } function reviveCompanionActorInWork(work: MutableCastWork, id: string, percentMaxHealth: number): number { const actor = ensureCompanionActorInWork(work, id); if (!actor || actor.health > 0) return 0; const health = Math.max(1, Math.round(actor.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)))); const next = Object.freeze({ ...actor, health, alive: true, controlledUntil: 0, cast: null }); work.actors[id] = next; mirrorCompanionActor(next); usePartyStore.setState((party) => ({ members: party.members.map((member) => member.id === id ? { ...member, status: "ready", controlMechanic: null } : member), })); return health; } function damageCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number { const actor = ensureCompanionActorInWork(work, id); const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0)); if (!actor || actor.health <= 0 || requested <= 0) return 0; const applied = Math.min(actor.health, requested); const health = actor.health - applied; const next = Object.freeze({ ...actor, health, alive: health > 0, cast: health > 0 ? actor.cast : null, ...(health <= 0 ? { cooldowns: Object.fromEntries(Object.entries(actor.cooldowns) .filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)), } : {}), }); work.actors[id] = next; mirrorCompanionActor(next); if (health <= 0) { work.auras = work.auras.filter((aura) => !(aura.targetId === id && aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG))); usePartyStore.setState((party) => ({ members: party.members.map((member) => member.id === id ? { ...member, status: "down", activeCast: null, activeCastTargetId: null, cooldowns: Object.fromEntries(Object.entries(member.cooldowns) .filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)) } : member), })); } return applied; } /** Charge the max-health self-cost once from the player's in-flight cast transaction. */ function applyRomPriestHolyCandleInWork( work: MutableCastWork, ownerActorId: string, healingAbilityId: string, now: number, ): boolean { if (ownerActorId !== PLAYER_AURA_ENTITY_ID || work.classId !== "rom-priest" || work.secondaryClassId !== null) return false; const lastChargeAt = work.romPriestHolyCandleLastChargeAt?.[ownerActorId] ?? null; const amount = romPriestHolyCandleSelfDamageAmount( work.maxHealth, romPriestHolyCandleIsActive(aurasForEntity(work.auras, ownerActorId, now)), now, lastChargeAt, ); const applied = Math.min(work.health, amount); if (applied <= 0) return false; work.health -= applied; work.progression.health = work.health; work.actors[ownerActorId] = { ...work.actors[ownerActorId]!, health: work.health, alive: work.health > 0, }; work.romPriestHolyCandleLastChargeAt = { ...(work.romPriestHolyCandleLastChargeAt ?? {}), [ownerActorId]: now, }; recordCombatEventInWork(work, { occurredAt: now, kind: "damage", sourceActorId: ownerActorId, targetActorId: ownerActorId, abilityId: healingAbilityId, school: "holy", rawAmount: amount, effectiveAmount: applied, overheal: 0, absorbed: 0, critical: false, }); return true; } function remainingEnemyControlAfterGrant(auras: readonly ActiveAura[], id: string, now: number): { readonly mechanic: EnemyControlMechanic; readonly endsAt: number; } | null { let remaining: { mechanic: EnemyControlMechanic; endsAt: number } | null = resolveEnemyTriggeredShoutControl(auras, id, now); const mechanics: readonly EnemyControlMechanic[] = ["confuse", "fear", "sleep", "root", "slow", "stun", "silence", "interrupt"]; for (const aura of auras) { if (aura.targetId !== id || aura.expiresAt === null || aura.expiresAt <= now || !aura.definition.id.startsWith("enemy-control:") || aura.definition.id.startsWith("enemy-control:source-spell-")) continue; const mechanic = mechanics.find((candidate) => aura.definition.id.endsWith(`:${candidate}`)); if (mechanic && (!remaining || aura.expiresAt > remaining.endsAt || aura.expiresAt === remaining.endsAt && mechanic === "stun")) { remaining = { mechanic, endsAt: aura.expiresAt }; } } return remaining; } function clearCompanionControlInWork(work: MutableCastWork, id: string, now?: number): boolean { const actor = ensureCompanionActorInWork(work, id); if (!actor || actor.controlledUntil <= 0) return false; const remaining = now === undefined ? null : remainingEnemyControlAfterGrant(work.auras, id, now); const next = Object.freeze({ ...actor, controlledUntil: remaining?.endsAt ?? 0 }); work.actors[id] = next; mirrorCompanionActor(next); usePartyStore.setState((party) => ({ members: party.members.map((member) => member.id === id ? { ...member, controlMechanic: remaining?.mechanic ?? null } : member), })); return true; } function combatActorSnapshots(state: CombatState): Readonly> { const actors: Record = {}; const resourceProfile = resourceProfileForClass(state.classId); const playerResources = Object.fromEntries( Object.entries(state.maxResourcePools).map(([id, maximum]) => [id, normalizeActorResourcePool( id, state.resourcePools[id as ResourceType] ?? 0, maximum ?? 0, id === resourceProfile.type ? resourceProfile.regenerationPerSecond : 0, state.lastResourceSpentAt[id as ResourceType] ?? 0, )]), ); if (useDungeonSimulationStore.getState().sessionMode === "play") actors[PLAYER_AURA_ENTITY_ID] = { id: PLAYER_AURA_ENTITY_ID, threatActorId: PLAYER_AURA_ENTITY_ID, kind: "player", classId: state.classId, statRuleset: state.statRuleset, level: state.level, health: state.health, maxHealth: state.maxHealth, absorb: state.shield + entityAbsorbAmount(state.auras, PLAYER_AURA_ENTITY_ID), resources: playerResources, cooldowns: state.cooldowns, globalCooldownEndsAt: state.globalCooldownEndsAt, cast: state.activeCast ? { abilityId: state.activeCast.abilityId, school: state.activeCast.school, mode: state.activeCast.mode, startedAt: state.activeCast.startedAt, completesAt: state.activeCast.completesAt, originalDurationMs: state.activeCast.originalDurationMs, pushbackHits: state.activeCast.pushbackHits, } : null, controlledUntil: state.controlledUntil, schoolLockedUntil: state.schoolLockedUntil, comboPoints: state.comboPoints, comboTargetId: state.comboTargetId, runes: state.deathKnightRunes, alive: state.health > 0, }; for (const member of usePartyStore.getState().members) { actors[member.id] = companionActorFromMember( member, entityAbsorbAmount(state.auras, member.id), state.actors[member.id], ); } for (const actor of onlinePlayerRuntimeActors()) actors[actor.id] = actor; for (const mob of Object.values(state.mobs)) { actors[mob.id] = { id: mob.id, threatActorId: mob.id, kind: "enemy", level: mob.level, health: mob.health, maxHealth: mob.maxHealth, absorb: entityAbsorbAmount(state.auras, mob.id), resources: mob.maxMana > 0 ? { mana: normalizeActorResourcePool("mana", mob.maxMana, mob.maxMana) } : {}, cooldowns: mob.abilityReadyAt, globalCooldownEndsAt: 0, cast: mob.activeCast ? { abilityId: mob.activeCast.attackId, school: mob.activeCast.school, mode: "cast", startedAt: mob.activeCast.startedAt, completesAt: mob.activeCast.completesAt, originalDurationMs: mob.activeCast.completesAt - mob.activeCast.startedAt, pushbackHits: 0, } : null, controlledUntil: Math.max(0, ...mob.statuses.map((status) => status.endsAt)), schoolLockedUntil: mob.schoolLockedUntil, comboPoints: 0, comboTargetId: null, runes: [], alive: !mob.dead, }; } for (const summon of state.summons) { const owner = usePartyStore.getState().members.find((member) => member.id === summon.ownerActorId); actors[summon.id] = combatActorFromSummon( summon, owner?.level ?? state.level, state.auras, owner?.statRuleset ?? state.statRuleset, ); } return Object.freeze(actors); } function initialState() { const classId: ClassId = "priest"; const statRuleset: CharacterStatRuleset = "wow"; const contentCategoryId: ContentCategoryId = "wow"; const raceId: RaceId = "human"; const gender: GenderId = "male"; const level = 1; const resource = resourceProfileForClass(classId); const talentRanks: TalentRanks = {}; const talentModifiers = characterStatModifiers(classId, level, talentRanks); const maxHealth = talentAdjustedMaxHealth(classId, level, talentModifiers, EMPTY_ITEM_STATS, raceId); const maxResource = talentAdjustedMaxResource( classId, level, talentModifiers, EMPTY_ITEM_STATS, raceId, ); const abilities = abilitiesForClass(classId); const initialPools = initialResourcePools(classId, abilities, maxResource); return { classId, statRuleset, contentCategoryId, hunterPetFamily: "wolf" as const, specializationId: null, gearRole: "healer" as PartyRole, secondaryClassId: null, raceId, gender, level, xp: 0, xpToNext: xpRequiredForNextLevel(level), health: maxHealth, maxHealth, controlledUntil: 0, controlMechanic: null, shield: 0, shieldExpiresAt: 0, resource: initialResourceForMaximum(classId, maxResource), maxResource, resourcePools: initialPools.pools, maxResourcePools: initialPools.maximums, lastResourceSpentAt: {}, romNativeRecoveryClock: undefined, warriorAngerManagementRageRemainderMs: 0, resourceName: resource.name, comboPoints: 0, comboTargetId: null, deathKnightRunes: [], abilities, cooldowns: {}, globalCooldownEndsAt: 0, activeCast: null, schoolLockedUntil: {}, playerAnimationEvent: null, autoAttackTargetId: null, nextAutoAttackAt: 0, nextOffhandAutoAttackAt: 0, autoShotTargetId: null, nextAutoShotAt: 0, mobs: {}, selectedTargetId: null, selectedFriendlyActorId: null, effects: [], enemyPeriodicEffects: [], killingSpreeWindows: [], auras: [], summons: [], areaEffects: [], pendingGroundTarget: null, actors: {}, combatEvents: [], romPriestHolyCandleLastChargeAt: {}, romMageDruidGreenGuardianStationaryProgress: {}, feedback: null, lastCombatResult: null, bossLootRewards: [] as readonly BossLootReward[], talentRanks, glyphIds: [], talentModifiers, actionBindings: defaultActionBindings(defaultActionBarForClass(classId)), inventory: [] as readonly InventoryItem[], bossCards: emptyBossCardCollection(), equipment: {} as EquipmentAssignments, gearStats: EMPTY_ITEM_STATS, settings: DEFAULT_GAMEPLAY_SETTINGS, playerPosition: [0, 0, 0] as CombatPosition, }; } export const useCombatStore = create((set, get) => ({ ...initialState(), initializeCharacter: (profile) => { resetCombatPresentationSession(); const classId = profile.classId; const statRuleset = normalizeCharacterStatRuleset(profile.statRuleset, classId); const specialization = specializationById(profile.specializationId); const specializationId = specialization?.classId === classId ? specialization.id : null; const gearRole = specialization?.role ?? profile.role ?? defaultPartyRoleForClass(classId); const secondaryClassId = profile.secondaryClassId ?? null; const raceId = profile.raceId ?? "human"; const gender = profile.gender ?? "male"; const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0); const resource = resourceProfileForClass(classId); const talentRanks = normalizeTalentRanks(classId, progression.level, profile.talentRanks ?? {}); const glyphIds = classId === "druid" && profile.glyphIds?.includes(WOW335_GLYPH_OF_SWIFTMEND_ID) ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : []; const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks); const abilities = abilitiesForCharacter(classId, secondaryClassId); // The selected source class owns the character namespace; dungeon and // untrusted seed metadata cannot change which game's items are usable. const contentCategoryId = contentCategoryForClass(classId); const inventory = normalizeInventory(profile.inventory, { ownerCategory: contentCategoryId }); const categoryInventory = inventory.filter((item) => inventoryItemUsableForCategory(item, contentCategoryId)); const equipment = normalizeEquipment(profile.equipment, inventory, contentCategoryId); const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, categoryInventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId, secondaryClassId, specializationId, role: gearRole, level: progression.level, }) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId, contentCategoryId); const maxHealth = talentAdjustedMaxHealth( classId, progression.level, talentModifiers, gearStats, raceId, secondaryClassId, gender, statRuleset, ); const maxResourceBase = talentAdjustedMaxResource( classId, progression.level, talentModifiers, gearStats, raceId, secondaryClassId, gender, statRuleset, ); const passiveAuras = syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()); const nativeManaMaximum = nativeManaPoolMaximum( classId, progression.level, talentModifiers, gearStats, raceId, secondaryClassId, gender, statRuleset, passiveAuras, ); const maxResource = nativeManaMaximum !== null && resourceProfileForClass(classId).type === "mana" ? nativeManaMaximum : maxResourceBase; // RoM secondary pools use native resource limits, not the mapped WoW class's mana. const initialPools = initialResourcePools(classId, abilities, maxResource, statRuleset === "rom" ? nativeManaMaximum ?? 0 : isRomClassId(classId) ? defaultResourceMaximum("mana") : deriveCharacterStats(baseClassFor(classId), progression.level, gearStats, raceId).mana, naturePowerMaximumForCharacter(classId, secondaryClassId, progression.level), statRuleset === "rom" && nativeManaMaximum !== null && nativeManaMaximum > 0); set((state) => ({ ...initialState(), classId, hunterPetFamily: classId === "hunter" ? normalizeWow335HunterPetFamily(profile.hunterPetFamily) : "wolf", specializationId, gearRole, secondaryClassId, raceId, gender, statRuleset, contentCategoryId, romNativeRecoveryClock: undefined, level: progression.level, xp: progression.xp, xpToNext: xpRequiredForNextLevel(progression.level), health: maxHealth, maxHealth, resource: initialResourceForMaximum(classId, maxResource), maxResource, resourcePools: initialPools.pools, maxResourcePools: initialPools.maximums, resourceName: resource.name, abilities, deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [], auras: passiveAuras, talentRanks, glyphIds, talentModifiers, actionBindings: withoutUnlearnedTalentBindings( normalizeActionBindings( profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level), abilities.filter((ability) => !ability.passive).map((ability) => ability.id), ), abilities, talentRanks, ), inventory: categoryInventory, bossCards: normalizeBossCardCollection(profile.bossCards), equipment, gearStats, settings: normalizedSettings(DEFAULT_GAMEPLAY_SETTINGS, profile.settings ?? {}), playerPosition: state.playerPosition, })); }, refreshResolvedGear: () => set((state) => { const categoryInventory = state.inventory.filter((item) => inventoryItemUsableForCategory(item, state.contentCategoryId)); const context: HeirloomContext = { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, }; const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(state.equipment, categoryInventory, context) : activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId); const maxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, state.auras, state.gender, state.statRuleset, ); const maxResourceBase = talentAdjustedMaxResource( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, ); const nativeManaMaximum = nativeManaPoolMaximum( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, state.auras, ); const maxResource = nativeManaMaximum !== null && resourceProfileForClass(state.classId).type === "mana" ? nativeManaMaximum : maxResourceBase; const resizedMana = nativeManaMaximum === null ? null : resizeManaPool(state.resourcePools, state.maxResourcePools, nativeManaMaximum, true); if (gearStats === state.gearStats && maxHealth === state.maxHealth && maxResource === state.maxResource && !resizedMana) return state; return { gearStats, maxHealth, maxResource, ...(resizedMana ? { resourcePools: resizedMana.pools, maxResourcePools: resizedMana.maximums, } : {}), health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth), resource: resourceProfileForClass(state.classId).type === "mana" && resizedMana ? resizedMana.pools.mana ?? 0 : clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource, 0, maxResource), }; }), registerMob: (id, registration) => set((state) => { if (!id) return state; const existing = state.mobs[id]; const boss = registration.boss ?? existing?.boss ?? false; const serverEntry = registration.serverEntry ?? existing?.serverEntry ?? null; const sourceCreatureGuid = registration.sourceCreatureGuid ?? existing?.sourceCreatureGuid; const runewakerNpcId = registration.runewakerNpcId ?? existing?.runewakerNpcId; const sourceEquipment = serverEntry !== null && runewakerNpcId === undefined && sourceCreatureGuid !== undefined && !existing?.summonedBy && !existing?.characterDefender ? enemySpawnEquipmentContract(serverEntry, sourceCreatureGuid, stableCombatUnit(`${id}:spawn-equipment`)) : undefined; const automaticDualWieldEnabled = serverEntry !== null && runewakerNpcId === undefined && (enemyFlagDrivenOffhandContract(serverEntry) !== undefined || Boolean(sourceEquipment?.autoOffhand)); const creatureFamily = registration.creatureFamily ?? (registration.serverEntry === undefined && existing?.creatureFamily ? existing.creatureFamily : romCreatureFamilyForServerEntry(serverEntry)); const lootSourceId = registration.lootSourceId ?? existing?.lootSourceId ?? null; const level = Math.max(1, Math.trunc(registration.level ?? existing?.level ?? state.level)); const resistances: MobMagicResistances = { arcane: Math.max(0, registration.resistances?.arcane ?? existing?.resistances.arcane ?? 0), fire: Math.max(0, registration.resistances?.fire ?? existing?.resistances.fire ?? 0), frost: Math.max(0, registration.resistances?.frost ?? existing?.resistances.frost ?? 0), nature: Math.max(0, registration.resistances?.nature ?? existing?.resistances.nature ?? 0), shadow: Math.max(0, registration.resistances?.shadow ?? existing?.resistances.shadow ?? 0), }; const defaults = defaultMobStats(level, boss, serverEntry ?? undefined); const mechanicImmunities = registration.mechanicImmunities ?? existing?.mechanicImmunities ?? defaults.mechanicImmunities; const schoolImmunities = registration.schoolImmunities ?? existing?.schoolImmunities ?? defaults.schoolImmunities; const healthMultiplier = Math.max(0.01, registration.healthMultiplier ?? existing?.healthMultiplier ?? 1); const damageMultiplier = Math.max(0.01, registration.damageMultiplier ?? existing?.damageMultiplier ?? 1); const nativeDamageBudgetScale = Math.max(0.01, registration.nativeDamageBudgetScale ?? existing?.nativeDamageBudgetScale ?? 1); const bonusLootChance = clamp( registration.bonusLootChance ?? existing?.bonusLootChance ?? 0, 0, 1, ); const maxHealth = Math.max(1, Math.round( registration.maxHealth ?? existing?.maxHealth ?? defaults.maxHealth * healthMultiplier, )); const xpReward = Math.max(0, Math.round(registration.xpReward ?? existing?.xpReward ?? defaults.xpReward)); const hasLoot = registration.hasLoot ?? existing?.hasLoot ?? false; const fallbackAttack = { ...DEFAULT_ENEMY_ATTACK, range: boss ? 5.2 : 4.3, cooldownMs: defaults.swingMs, }; const attacks = registration.attacks ? normalizeEnemyAttacks(registration.attacks, fallbackAttack, runewakerNpcId) : existing?.attacks ?? normalizeEnemyAttacks(undefined, fallbackAttack, runewakerNpcId); const attackRange = maximumEnemyAttackRange(attacks); const swingMs = defaults.swingMs; const leashRange = Math.max( attackRange + 1, registration.leashRange ?? existing?.leashRange ?? (boss ? 55 : 42), ); const aggroRange = Math.max( 0, Math.min( leashRange, registration.aggroRange ?? existing?.aggroRange ?? (boss ? DEFAULT_BOSS_AGGRO_RANGE : DEFAULT_MOB_AGGRO_RANGE), ), ); const moveSpeed = Math.max( 0, registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5), ); const attackDamage = existing && level === existing.level && (registration.damageMultiplier !== undefined || registration.nativeDamageBudgetScale !== undefined) ? Math.max(1, Math.round( existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)) * (nativeDamageBudgetScale / Math.max(0.01, existing.nativeDamageBudgetScale ?? 1)), )) : (existing && level === existing.level ? existing.attackDamage : undefined) ?? Math.max(1, Math.round( defaults.attackDamage * damageMultiplier * nativeDamageBudgetScale, )); const mob: MobCombatState = existing ? { ...existing, name: registration.name || existing.name, serverEntry, sourceCreatureGuid, sourceEquipment, dualWieldEnabled: existing.dualWieldEnabled || automaticDualWieldEnabled, runewakerNpcId, creatureFamily, boss, lootSourceId, level, armor: defaults.armor, maxMana: defaults.maxMana, attackPower: defaults.attackPower, attackPowerDamageContribution: defaults.attackPowerDamageContribution * damageMultiplier * nativeDamageBudgetScale, mechanicImmunities, schoolImmunities, resistances, maxHealth, healthMultiplier, damageMultiplier, nativeDamageBudgetScale, bonusLootChance, attacks, attackRange, swingMs, aggroRange, leashRange, moveSpeed, attackDamage, health: existing.dead || existing.health <= 0 ? 0 : clamp(Math.round(existing.health / Math.max(1, existing.maxHealth) * maxHealth), 1, maxHealth), xpReward, hasLoot, } : { id, name: registration.name || "Unknown Creature", serverEntry, sourceCreatureGuid, sourceEquipment, runewakerNpcId, creatureFamily, boss, lootSourceId, level, armor: defaults.armor, maxMana: defaults.maxMana, attackPower: defaults.attackPower, attackPowerDamageContribution: defaults.attackPowerDamageContribution * damageMultiplier * nativeDamageBudgetScale, mechanicImmunities, schoolImmunities, resistances, health: maxHealth, maxHealth, healthMultiplier, damageMultiplier, nativeDamageBudgetScale, bonusLootChance, dead: false, hasLoot, pendingLoot: [], looted: false, despawned: false, diedAt: null, corpseDespawnAt: null, xpReward, experienceGranted: false, statuses: [], controlDiminishingReturns: {}, tauntDiminishingReturns: { applications: 0, resetAt: 0 }, auras: [], threatByActor: {}, targetActorId: null, forcedTarget: null, lastDamageAt: 0, engaged: false, combatPhase: "idle", homePosition: getMobPosition(id), nextAttackAt: 0, dualWieldEnabled: automaticDualWieldEnabled, nextEnemyOffhandAttackAt: 0, pendingExtraAttacks: [], lastDamagedActorId: null, abilityReadyAt: {}, abilityCastCounts: {}, activeCast: null, schoolLockedUntil: {}, attackRange, attackDamage, swingMs, aggroRange, leashRange, moveSpeed, attackRevision: 0, attackAnimation: "attack", attacks, nextAttackIndex: 0, lastAttackId: null, woundRevision: 0, nextWoundAnimationAt: 0, deathRevision: 0, }; return { mobs: { ...state.mobs, [id]: mob } }; }), removeMob: (id) => set((state) => { if (!state.mobs[id]) return state; const mobs = { ...state.mobs }; delete mobs[id]; return { mobs, selectedTargetId: state.selectedTargetId === id ? null : state.selectedTargetId, autoAttackTargetId: state.autoAttackTargetId === id ? null : state.autoAttackTargetId, nextAutoAttackAt: state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt, nextOffhandAutoAttackAt: state.autoAttackTargetId === id ? 0 : state.nextOffhandAutoAttackAt, autoShotTargetId: state.autoShotTargetId === id ? null : state.autoShotTargetId, nextAutoShotAt: state.autoShotTargetId === id ? 0 : state.nextAutoShotAt, comboPoints: state.selectedTargetId === id ? 0 : state.comboPoints, effects: state.effects.filter((effect) => effect.targetId !== id), auras: removeEnemyActorAuras(state.auras, id), }; }), lootMob: (id, now = combatNow()) => { const mob = get().mobs[id]; if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false; const ownerCategory = get().contentCategoryId; const acceptedLoot = mob.pendingLoot.filter((item) => inventoryItemUsableForCategory(item, ownerCategory)); const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS); const lootMessage = acceptedLoot.length ? `Looted ${acceptedLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.` : `Looted ${mob.name}.`; set((state) => { const bossReward = mob.boss ? { id: ++bossLootRewardSequence, bossId: id, bossName: mob.name, items: acceptedLoot, ...(mob.bossCardDrop ? { cards: mob.bossCardDrop } : {}), acquiredAt: now, } satisfies BossLootReward : null; return { inventory: [...state.inventory, ...acceptedLoot], bossCards: mob.bossCardDrop ? collectBossCardDrop(state.bossCards, mob.bossCardDrop) : state.bossCards, mobs: { ...state.mobs, [id]: { ...state.mobs[id], looted: true, corpseDespawnAt, despawned: now >= corpseDespawnAt, }, }, ...(bossReward ? { bossLootRewards: [...state.bossLootRewards, bossReward] } : { feedback: nextFeedback("loot", lootMessage, { targetId: id }) }), }; }); return true; }, dismissBossLootReward: (id) => set((state) => ({ bossLootRewards: id === undefined ? state.bossLootRewards.slice(1) : state.bossLootRewards.filter((reward) => reward.id !== id), })), collectSharedBossCards: (drop, bossName, now) => set((state) => { const bossCards = collectBossCardDrop(state.bossCards, drop); if (bossCards === state.bossCards) return state; return { bossCards, bossLootRewards: [...state.bossLootRewards, { id: ++bossLootRewardSequence, bossId: drop.spawnId, bossName, items: [], cards: drop, acquiredAt: now, }], }; }), equipItem: (instanceId, ownerId = PLAYER_EQUIPMENT_OWNER_ID) => { const state = get(); const ownerMember = ownerId === PLAYER_EQUIPMENT_OWNER_ID ? null : usePartyStore.getState().members.find((member) => member.id === ownerId); const ownerClassId = ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.classId : ownerMember?.classId; if (!ownerClassId) { set({ feedback: nextFeedback("error", "That party member is not available.") }); return false; } const change = equipInventoryItem( state.equipment, state.inventory, ownerId, ownerClassId, instanceId, { secondaryClassId: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.secondaryClassId : ownerMember?.secondaryClassId, level: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.level : ownerMember?.level, ownerCategory: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.contentCategoryId : usePartyStore.getState().categoryId ?? contentCategoryForClass(ownerClassId), }, ); if (!change.ok) { set({ feedback: nextFeedback("error", change.reason ?? "That item cannot be equipped.") }); return false; } const item = state.inventory.find((candidate) => candidate.instanceId === instanceId); const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID ? "You" : ownerMember?.name ?? "Companion"; const categoryInventory = state.inventory.filter((candidate) => inventoryItemUsableForCategory( candidate, state.contentCategoryId, )); const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, categoryInventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, }) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId); const maxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, state.auras, state.gender, state.statRuleset, ); const maxResourceBase = talentAdjustedMaxResource( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, ); const nativeManaMaximum = nativeManaPoolMaximum( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, state.auras, ); const maxResource = nativeManaMaximum !== null && resourceProfileForClass(state.classId).type === "mana" ? nativeManaMaximum : maxResourceBase; const resizedMana = nativeManaMaximum === null ? null : resizeManaPool(state.resourcePools, state.maxResourcePools, nativeManaMaximum, true); set({ equipment: change.assignments, gearStats, maxHealth, maxResource, ...(resizedMana ? { resourcePools: resizedMana.pools, maxResourcePools: resizedMana.maximums, } : {}), health: clamp( state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth, ), resource: clamp( resourceProfileForClass(state.classId).type === "mana" && resizedMana ? resizedMana.pools.mana ?? 0 : state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource, 0, maxResource, ), feedback: nextFeedback("success", `${ownerName} equipped ${item?.name ?? "the item"}.`), }); const partyCategory = usePartyStore.getState().categoryId; usePartyStore.getState().syncEquipment( change.assignments, state.inventory.filter((candidate) => inventoryItemUsableForCategory(candidate, partyCategory)), ); return true; }, unequipItem: (ownerId, slot) => { const state = get(); const next = unequipInventorySlot(state.equipment, ownerId, slot); if (next === state.equipment) return false; const categoryInventory = state.inventory.filter((candidate) => inventoryItemUsableForCategory( candidate, state.contentCategoryId, )); const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, categoryInventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, }) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId); const maxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, state.auras, state.gender, state.statRuleset, ); const maxResourceBase = talentAdjustedMaxResource( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, ); const nativeManaMaximum = nativeManaPoolMaximum( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, state.auras, ); const maxResource = nativeManaMaximum !== null && resourceProfileForClass(state.classId).type === "mana" ? nativeManaMaximum : maxResourceBase; const resizedMana = nativeManaMaximum === null ? null : resizeManaPool(state.resourcePools, state.maxResourcePools, nativeManaMaximum, true); set({ equipment: next, gearStats, maxHealth, maxResource, ...(resizedMana ? { resourcePools: resizedMana.pools, maxResourcePools: resizedMana.maximums, } : {}), health: clamp( state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth, ), resource: clamp( resourceProfileForClass(state.classId).type === "mana" && resizedMana ? resizedMana.pools.mana ?? 0 : state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource, 0, maxResource, ), feedback: nextFeedback("success", "Item returned to the shared inventory."), }); usePartyStore.getState().syncEquipment(next, state.inventory); return true; }, selectMob: (id) => { const mob = get().mobs[id]; if (!mob || mob.dead) return false; set((state) => ({ selectedTargetId: id, autoAttackTargetId: state.autoAttackTargetId ? id : null, nextAutoAttackAt: state.autoAttackTargetId && state.autoAttackTargetId !== id ? 0 : state.nextAutoAttackAt, nextOffhandAutoAttackAt: state.autoAttackTargetId && state.autoAttackTargetId !== id ? 0 : state.nextOffhandAutoAttackAt, autoShotTargetId: state.autoShotTargetId ? id : null, nextAutoShotAt: state.autoShotTargetId ? state.nextAutoShotAt : 0, comboPoints: state.selectedTargetId === id ? state.comboPoints : 0, comboTargetId: state.selectedTargetId === id ? state.comboTargetId : id, feedback: nextFeedback("target", mob.name, { targetId: id }), })); return true; }, selectNearestTarget: (origin, maxRange = 40) => { const state = get(); const id = eligibleTargetIds(state, currentOrigin(state, origin), maxRange)[0] ?? null; if (id) state.selectMob(id); else state.clearTarget(); return id; }, cycleTarget: (direction = "next", origin, maxRange = 40) => { const state = get(); const ids = eligibleTargetIds(state, currentOrigin(state, origin), maxRange); if (!ids.length) { state.clearTarget(); return null; } const step = direction === "previous" || direction === -1 ? -1 : 1; const currentIndex = state.selectedTargetId ? ids.indexOf(state.selectedTargetId) : -1; const nextIndex = currentIndex < 0 ? (step > 0 ? 0 : ids.length - 1) : (currentIndex + step + ids.length) % ids.length; const id = ids[nextIndex]; state.selectMob(id); return id; }, clearTarget: () => set((state) => ({ selectedTargetId: null, autoAttackTargetId: null, nextAutoAttackAt: 0, nextOffhandAutoAttackAt: 0, autoShotTargetId: null, nextAutoShotAt: 0, comboPoints: 0, comboTargetId: null, feedback: state.selectedTargetId ? nextFeedback("target", "Target cleared.") : state.feedback, })), selectFriendlyActor: (id) => { if (id === null) { set({ selectedFriendlyActorId: null }); return true; } const state = get(); const actor = state.actors[id]; if (!actor || !["player", "companion", "pet", "totem", "summon"].includes(actor.kind)) return false; set({ selectedFriendlyActorId: id, feedback: nextFeedback("target", `Friendly target selected: ${state.summons.find((summon) => summon.id === id)?.name ?? id}.`, { targetId: id }), }); return true; }, setPlayerPosition: (position) => set((state) => { const next: CombatPosition = [position[0], position[1], position[2]]; const cancellationAt = combatNow(); const movementLocked = state.auras.some((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && (aura.expiresAt === null || aura.expiresAt > combatNow()) && aura.definition.utilityTags?.includes("rom-druid-movement-locked") )); if (movementLocked) return state; const cast = state.activeCast; const movedWhileCasting = Boolean(cast && !cast.canCastWhileMoving && ( (next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04 )); const positionUnchanged = ( (next[0] - state.playerPosition[0]) ** 2 + (next[1] - state.playerPosition[1]) ** 2 + (next[2] - state.playerPosition[2]) ** 2 ) <= 1e-8; if (positionUnchanged && !movedWhileCasting) return state; if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, cancellationAt, next); return { playerPosition: next, activeCast: movedWhileCasting ? null : cast, ...(movedWhileCasting && cast ? romPriestChampionRageControlAwardPatch(state, cast, cancellationAt) : {}), combatEvents: movedWhileCasting ? recordCombatEvent(state.combatEvents, { occurredAt: cancellationAt, kind: "interrupt", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: cast!.abilityId, school: cast!.school, rawAmount: 0, effectiveAmount: 0, overheal: 0, absorbed: 0, critical: false, outcome: "movement", }) : state.combatEvents, playerAnimationEvent: movedWhileCasting ? nextAnimationEvent("ability-cancel", cast!.abilityId) : state.playerAnimationEvent, feedback: movedWhileCasting ? nextFeedback("error", "Casting interrupted by movement.", { abilityId: cast!.abilityId }) : state.feedback, }; }), beginGroundTarget: (abilityId) => { const ability = abilityById(abilityId); if (!ability || targetingRulesForAbility(ability).shape !== "ground") return false; const state = get(); const targeting = targetingRulesForAbility(ability); const maximumRange = talentAdjustedCastRange( targeting.groundRange ?? ability.range.max, ability, state.talentModifiers, ); const player = combatSpatialActor(PLAYER_AURA_ENTITY_ID); const yaw = player?.yaw ?? 0; const distance = Math.min(10, Math.max(0, maximumRange)); const requested: CombatPosition = [ state.playerPosition[0] + Math.sin(yaw) * distance, state.playerPosition[1], state.playerPosition[2] + Math.cos(yaw) * distance, ]; const projected = projectCombatGroundPoint(requested, maximumRange, state.playerPosition); set({ pendingGroundTarget: { abilityId, maximumRange, radius: targeting.radius ?? ability.radius ?? 4, position: projected ?? requested, valid: projected !== null && (!targeting.requiresLineOfSight || combatHasLineOfSight(state.playerPosition, projected)), }, feedback: nextFeedback("target", `Choose a location for ${ability.name}.`, { abilityId }), }); return true; }, moveGroundTarget: (moveX, moveForward, yaw, deltaSeconds) => set((state) => { const pending = state.pendingGroundTarget; if (!pending) return state; const magnitude = Math.hypot(moveX, moveForward); if (magnitude < 0.05 || !Number.isFinite(deltaSeconds)) return state; const normalizedX = moveX / Math.max(1, magnitude); const normalizedForward = moveForward / Math.max(1, magnitude); const safeYaw = Number.isFinite(yaw) ? yaw : 0; const worldX = -normalizedX * Math.cos(safeYaw) + normalizedForward * Math.sin(safeYaw); const worldZ = normalizedX * Math.sin(safeYaw) + normalizedForward * Math.cos(safeYaw); const speed = 10; const requested: CombatPosition = [ pending.position[0] + worldX * speed * clamp(deltaSeconds, 0, 0.1), pending.position[1], pending.position[2] + worldZ * speed * clamp(deltaSeconds, 0, 0.1), ]; const projected = projectCombatGroundPoint(requested, pending.maximumRange, state.playerPosition); const ability = abilityById(pending.abilityId); const targeting = ability ? targetingRulesForAbility(ability) : null; const valid = Boolean(projected && Math.sqrt(distanceSquared(state.playerPosition, projected)) <= pending.maximumRange && (!targeting?.requiresLineOfSight || combatHasLineOfSight(state.playerPosition, projected))); return { pendingGroundTarget: { ...pending, position: projected ?? pending.position, valid, }, }; }), confirmGroundTarget: (position, now = combatNow()) => { const pending = get().pendingGroundTarget; if (!pending) return emptyCastResult("invalid-destination"); const destination = position ?? pending.position; if (!pending.valid && position === undefined) return emptyCastResult("invalid-destination", pending.abilityId); set({ pendingGroundTarget: null }); return get().castAbility(pending.abilityId, { origin: get().playerPosition, groundPosition: destination, now }); }, cancelGroundTarget: () => set({ pendingGroundTarget: null }), synchronizeActors: () => { const partyMembers = usePartyStore.getState().members; const livingCompanionIds = new Set(partyMembers.filter((member) => member.health > 0).map((member) => member.id)); let actors: Readonly> = {}; set((state) => { const activeBloodArrowOwners = new Set(livingCompanionIds); if (state.health > 0) activeBloodArrowOwners.add(PLAYER_AURA_ENTITY_ID); actors = Object.freeze(Object.fromEntries(Object.entries(combatActorSnapshots(state)).map(([id, actor]) => ( activeBloodArrowOwners.has(id) ? [id, actor] : [id, { ...actor, cooldowns: Object.fromEntries(Object.entries(actor.cooldowns) .filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)), }] )))); const auras = state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG) || aura.sourceId !== aura.targetId || (aura.sourceId === PLAYER_AURA_ENTITY_ID ? state.health > 0 : livingCompanionIds.has(aura.sourceId))); const cooldowns = state.health > 0 ? state.cooldowns : Object.fromEntries(Object.entries(state.cooldowns).filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)); return { actors, auras, cooldowns }; }); usePartyStore.setState((party) => ({ members: party.members.map((member) => { const runtime = memberFromCompanionActor(member, actors[member.id]); return member.health > 0 ? runtime : { ...runtime, cooldowns: Object.fromEntries(Object.entries(runtime.cooldowns) .filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)), }; }), })); }, applyDefaultPartyBuffs: (now = combatNow()) => { get().synchronizeActors(); const partyMembers = usePartyStore.getState().members; set((state) => { const auras = state.auras.filter((aura) => !aura.definition.exclusiveGroup?.startsWith("gm-party-buff:")); if (auras.length === state.auras.length) return state; const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, auras, state.gender, state.statRuleset); const health = state.health <= 0 ? 0 : clamp(state.health + maxHealth - state.maxHealth, 1, maxHealth); const nativeManaMaximum = nativeManaPoolMaximum( state.classId, state.level, state.talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, auras, PLAYER_AURA_ENTITY_ID, ); const resizedPlayerMana = nativeManaMaximum !== null && (state.resourcePools.mana !== undefined || state.maxResourcePools.mana !== undefined) ? resizeManaPool(state.resourcePools, state.maxResourcePools, nativeManaMaximum, false) : null; const primaryManaResource = resourceProfileForClass(state.classId).type === "mana"; const actors = { ...state.actors }; if (actors[PLAYER_AURA_ENTITY_ID]) actors[PLAYER_AURA_ENTITY_ID] = { ...actors[PLAYER_AURA_ENTITY_ID], health, maxHealth, ...(resizedPlayerMana ? { resources: { ...actors[PLAYER_AURA_ENTITY_ID].resources, mana: normalizeActorResourcePool( "mana", resizedPlayerMana.pools.mana ?? 0, resizedPlayerMana.maximums.mana ?? nativeManaMaximum!, actors[PLAYER_AURA_ENTITY_ID].resources.mana?.regenerationPerSecond ?? 0, actors[PLAYER_AURA_ENTITY_ID].resources.mana?.lastSpentAt ?? 0, ), }, } : {}), }; for (const member of partyMembers) { const actor = actors[member.id]; if (!actor) continue; const base = deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", ); const delta = auraMaximumHealthBonus(base, auras, member.id) - auraMaximumHealthBonus(base, state.auras, member.id); const memberMaxHealth = Math.max(1, actor.maxHealth + delta); actors[member.id] = { ...actor, maxHealth: memberMaxHealth, health: actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, memberMaxHealth), }; const memberNativeManaMaximum = nativeManaPoolMaximum( member.classId, member.level, [], member.gearStats, member.raceId, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", auras, member.id, ); if (memberNativeManaMaximum !== null && (actor.resources.mana !== undefined || member.resourcePools.mana !== undefined || member.resourceMaximums?.mana !== undefined)) { const previousMana = actor.resources.mana; const mana = normalizeActorResourcePool( "mana", previousMana?.current ?? member.resourcePools.mana ?? 0, memberNativeManaMaximum, previousMana?.regenerationPerSecond ?? 0, previousMana?.lastSpentAt ?? member.lastResourceSpentAt.mana ?? 0, ); actors[member.id] = { ...actors[member.id], resources: { ...actors[member.id].resources, mana }, }; } } return { auras, health, maxHealth, actors, ...(resizedPlayerMana ? { resourcePools: resizedPlayerMana.pools, maxResourcePools: resizedPlayerMana.maximums, ...(primaryManaResource ? { resource: resizedPlayerMana.pools.mana ?? state.resource, maxResource: nativeManaMaximum!, } : {}), } : {}), }; }); usePartyStore.setState((party) => ({ members: party.members.map((member) => memberFromCompanionActor(member, get().actors[member.id])), })); get().synchronizeActors(); const state = get(); const rules = usePartyBuffPolicyStore.getState().config.rules; const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play"; const sources = [ ...(includePlayer ? [{ id: PLAYER_AURA_ENTITY_ID, classId: state.classId, role: state.gearRole, level: state.level, abilities: state.abilities, }] : []), ...partyMembers.filter((member) => member.health > 0).map((member) => ({ id: member.id, classId: member.classId, role: member.role, level: member.level, abilities: abilitiesForCharacter(member.classId, member.secondaryClassId), })), ]; const selected = new Map[]; potency: number; scope: "party" | "tank" | "healer"; }>(); for (const rule of rules) { for (const source of sources) { const catalogAbility = source.abilities.find((ability) => ability.id === rule.abilityId); if (!catalogAbility || !isAbilityUnlocked(catalogAbility, source.level)) continue; const ability = abilityAtLevel(catalogAbility, source.level); const effects = permanentBuffEffects(ability); if (!effects.length) continue; const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id; const potency = defaultPartyBuffPotency(effects); if ((selected.get(family)?.potency ?? Number.NEGATIVE_INFINITY) >= potency) continue; selected.set(family, { sourceId: source.id, ability, effects, potency, scope: rule.scope }); } } for (const [family, buff] of selected) { const recipients = sources.filter((source) => buff.scope === "party" || source.role === buff.scope) .map((source) => source.id); for (const recipientId of recipients) { for (const effect of buff.effects) { get().applyActorAura(buff.sourceId, recipientId, buff.ability, { ...effect, aura: { ...effect.aura, durationMs: null, uniqueBySource: false, exclusiveGroup: `gm-party-buff:${family}`, }, }, now); } } } get().synchronizeActors(); }, applyPrecombatPreparation: (now = combatNow()) => { get().synchronizeActors(); const state = get(); const partyMembers = usePartyStore.getState().members; const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play"; const sources = [ ...(includePlayer ? [{ id: PLAYER_AURA_ENTITY_ID, classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, specialization: specializationById(state.specializationId)?.displayName ?? null, role: state.gearRole, level: state.level, talentRanks: state.talentRanks, abilities: state.abilities, }] : []), ...partyMembers.filter((member) => member.health > 0).map((member) => ({ id: member.id, classId: member.classId, secondaryClassId: member.secondaryClassId ?? null, specializationId: member.specializationId, specialization: member.specialization, role: member.role, level: member.level, talentRanks: member.talentRanks, abilities: abilitiesForCharacter(member.classId, member.secondaryClassId), })), ]; for (const source of sources) { const rotation = rotationProfileFor(source.classId, source.specializationId, source.specialization); const priority = (ability: AbilityDefinition) => { const index = rotation?.abilityPriority.indexOf(ability.name) ?? -1; return index < 0 ? Number.MAX_SAFE_INTEGER : index; }; const abilities = source.abilities .map((ability) => learnedAbilityForBuild(ability, source.level, source.talentRanks)) .filter((ability): ability is AbilityDefinition => ability !== null) .filter(abilityIsPrecombatPreparation) .filter((ability) => !rotation || priority(ability) < Number.MAX_SAFE_INTEGER) .sort((left, right) => priority(left) - priority(right) || left.name.localeCompare(right.name)); for (const ability of abilities) { const effects = precombatPreparationEffects(ability); const auraEffects = effects.filter((effect): effect is Extract => ( effect.kind === "apply-aura" )); const current = get(); const maintained = current.auras.some((aura) => ( aura.targetId === source.id && (aura.expiresAt === null || aura.expiresAt > now) && auraEffects.some((effect) => effect.aura.id === aura.definition.id) )); const blockedByExclusiveGroup = current.auras.some((aura) => ( aura.targetId === source.id && (aura.expiresAt === null || aura.expiresAt > now) && Boolean(aura.definition.exclusiveGroup) && auraEffects.some((effect) => effect.aura.exclusiveGroup === aura.definition.exclusiveGroup) )); if (!maintained && !blockedByExclusiveGroup) { for (const effect of auraEffects) get().applyActorAura(source.id, source.id, ability, effect, now); } const petEffects = effects.filter((effect): effect is Extract => ( effect.kind === "pet" )); if (!petEffects.length) continue; const hasActivePet = get().summons.some((summon) => { if (summon.ownerActorId !== source.id || summon.health <= 0 || summon.expiresAt <= now) return false; const summonAbility = abilityById(summon.sourceAbilityId); return summonAbility?.effects.some((effect) => effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false; }); if (hasActivePet) continue; const position = actorPosition(get(), source.id) ?? get().playerPosition; const petEffect = petEffects[0]; get().summonActor(source.id, ability, { kind: "summon", creatureId: petEffect.creatureId ?? 1, coefficient: 0.35, durationMs: Number.MAX_SAFE_INTEGER - now, }, position, null, now); } } get().synchronizeActors(); }, commitCompanionActors: (members) => { const livingCompanionIds = new Set(members.filter((member) => member.health > 0).map((member) => member.id)); let actors: Readonly> = {}; set((state) => { const next = { ...state.actors }; for (const member of members) { const existing = next[member.id] ?? companionActorFromMember( member, entityAbsorbAmount(state.auras, member.id), ); const runtime = companionActorWithMemberRuntime(existing, member); next[member.id] = member.health > 0 ? runtime : { ...runtime, cooldowns: Object.fromEntries(Object.entries(runtime.cooldowns) .filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)), }; } const activeBloodArrowOwners = new Set(livingCompanionIds); if (state.health > 0) activeBloodArrowOwners.add(PLAYER_AURA_ENTITY_ID); for (const [id, actor] of Object.entries(next)) { if (activeBloodArrowOwners.has(id) || actor.cooldowns[ROM_SCOUT_BLOOD_ARROW_ABILITY_ID] === undefined) continue; next[id] = { ...actor, cooldowns: Object.fromEntries(Object.entries(actor.cooldowns) .filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)), }; } actors = Object.freeze(next); const auras = state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG) || aura.sourceId !== aura.targetId || (aura.sourceId === PLAYER_AURA_ENTITY_ID ? state.health > 0 : livingCompanionIds.has(aura.sourceId))); return { actors, auras }; }); usePartyStore.setState((party) => ({ members: party.members.map((member) => { const runtime = members.find((candidate) => candidate.id === member.id) ?? member; const projected = memberFromCompanionActor(runtime, actors[member.id]); return projected.health > 0 ? projected : { ...projected, cooldowns: Object.fromEntries(Object.entries(projected.cooldowns) .filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)), }; }), })); }, castAbility: (abilityId, requestOrOrigin, legacyNow = combatNow(), legacyCompletingCast) => { const request: InternalCastRequest = Array.isArray(requestOrOrigin) ? { origin: requestOrOrigin as CombatPosition, now: legacyNow, completingCast: legacyCompletingCast } : (requestOrOrigin as InternalCastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast }; const origin = request.origin; const now = request.now ?? legacyNow; const completingCast = request.completingCast ?? legacyCompletingCast; const state = get(); const party = usePartyStore.getState(); const catalogAbility = state.classId.startsWith("rom-") ? state.abilities.find((candidate) => candidate.id === abilityId) ?? abilityById(abilityId) : abilityById(abilityId); const talentNodeId = catalogAbility ? abilityTalentNodeId(catalogAbility) : null; const learnedTalentRank = catalogAbility ? learnedAbilityRank(catalogAbility, state.talentRanks) : undefined; let ability = catalogAbility ? abilityAtLevel(catalogAbility, state.level, learnedTalentRank) : null; if (ability) ability = coaWitchDoctorBrewingAbilityForCast(ability, state.auras, PLAYER_AURA_ENTITY_ID, now); if (ability) ability = coaGuardianFormationAbilityForCast(ability); if (ability) ability = projectCoaGuardianParagonAbility(ability, { id: PLAYER_AURA_ENTITY_ID, classId: state.classId, talentRanks: state.talentRanks, role: state.gearRole, specializationId: state.specializationId, health: state.health, maxHealth: state.maxHealth, }, state.auras, now); if (ability && isCoaBarbarianDecapitate(ability)) { const mainHand = resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, })["main-hand"]; ability = coaBarbarianDecapitateWeaponStrike(ability, state.gearStats.meleeWeapon, mainHand ? coaNormalizedMeleeWeaponSpeed(mainHand) : null); } const suddenDoomFreeDeathCoil = request[WOW335_DEATH_KNIGHT_SUDDEN_DOOM_FREE_CAST] === true && ability?.id === WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID; const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID); // An active toggle must always be stoppable, even if the target has moved // out of range, the player has turned away, or another cast is underway. if (!completingCast && ability && isRomScoutAutoShotAbility(ability) && isRomScoutRogueAutoShotOwner(state) && state.abilities.some((entry) => entry.id === ability!.id) && state.autoShotTargetId) { set({ autoShotTargetId: null, nextAutoShotAt: 0, feedback: nextFeedback("success", "Auto Shot stopped.", { abilityId }), }); return { ok: true, abilityId, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0, }; } if (!completingCast && ability && isRomScoutBloodArrowAbility(ability) && isRomBloodArrowOwner({ classId: state.classId, secondaryClassId: state.secondaryClassId }) && state.abilities.some((entry) => entry.id === ability!.id) && hasOwnedRomScoutBloodArrow(state.auras, PLAYER_AURA_ENTITY_ID, now) && get().deactivateRomScoutBloodArrow(PLAYER_AURA_ENTITY_ID, now)) { return { ok: true, abilityId, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0, }; } const druidMasterShapeshifterHealingModifiers = state.classId === "druid" ? wow335DruidMasterShapeshifterModifiersForForm( state.talentRanks[WOW335_DRUID_MASTER_SHAPESHIFTER_TALENT_ID] ?? 0, wow335DruidShapeshiftFormForAuras(state.auras, PLAYER_AURA_ENTITY_ID, now), ).filter((modifier) => modifier.kind === "healing-percent") : []; const rimeReadyAura = state.classId === "death-knight" && ability?.name === "Howling Blast" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_RIME_READY_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const consumesRimeReady = Boolean(rimeReadyAura); const lockAndLoadTalentRank = state.classId === "hunter" ? Math.max(0, Math.min(3, Math.trunc(state.talentRanks["talent-1306"] ?? 0))) : 0; const hunterLockAndLoadAura = ability && lockAndLoadTalentRank > 0 && ["Arcane Shot", "Explosive Shot"].includes(ability.name) ? activePlayerAuras.find((aura) => aura.stacks > 0 && aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const consumesHunterLockAndLoad = Boolean(hunterLockAndLoadAura && lockAndLoadRankMechanics(lockAndLoadTalentRank)); const romWarriorPassiveSpellIds = state.classId === "rom-warrior" ? state.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-warrior" && (!candidate.secondaryClassId || candidate.secondaryClassId === state.secondaryClassId) && candidate.passive && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= state.level) .map((candidate) => candidate.dbcSpellId) : []; const romKnightWarriorPassiveSpellIds = state.classId === "rom-knight" && state.secondaryClassId === "rom-warrior" ? state.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-knight" && candidate.secondaryClassId === "rom-warrior" && candidate.dbcSpellId === 491639 && candidate.passive && learnedAbilityForBuild(candidate, state.level, state.talentRanks) !== null) .map((candidate) => candidate.dbcSpellId) : []; const romKnightCompassionPassiveSpellIds = state.classId === "rom-knight" && state.secondaryClassId === "rom-priest" ? state.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-knight" && candidate.secondaryClassId === "rom-priest" && candidate.dbcSpellId === 491635 && candidate.passive && learnedAbilityForBuild(candidate, state.level, state.talentRanks) !== null) .map((candidate) => candidate.dbcSpellId) : []; const romMageWarriorMeditationPathPassiveSpellIds = state.classId === "rom-mage" && state.secondaryClassId === "rom-warrior" ? state.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-mage" && candidate.secondaryClassId === "rom-warrior" && candidate.passive && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= state.level) .map((candidate) => candidate.dbcSpellId) : []; const warriorAdjustment = ability ? romWarriorAbilityAdjustment(ability, romWarriorPassiveSpellIds) : { rageCostReduction: 0, cooldownReductionMs: 0 }; const warriorDruidWhirlwindAdjustment = ability ? romWarriorDruidWhirlwindAdjustment({ classId: state.classId, secondaryClassId: state.secondaryClassId, learnedPassiveSpellIds: romWarriorPassiveSpellIds, ability, }) : { rageCostReduction: 0, cooldownReductionMs: 0 }; const knightWarriorWhirlwindAdjustment = ability ? romKnightWarriorWhirlwindAdjustment( state.classId, state.secondaryClassId, ability, romKnightWarriorPassiveSpellIds, ) : { rageCostReduction: 0, cooldownReductionMs: 0 }; const knightCompassionCooldownReductionMs = ability ? romKnightCompassionWhirlwindShieldCooldownReductionMs( state.classId, state.secondaryClassId, ability, romKnightCompassionPassiveSpellIds, ) : 0; const rogueShadowWalkerPassiveSpellIds = state.classId === "rom-rogue" && state.secondaryClassId === "rom-priest" ? state.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-rogue" && candidate.secondaryClassId === "rom-priest" && candidate.passive && candidate.dbcSpellId === ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= state.level) .map((candidate) => candidate.dbcSpellId) : []; const rogueShadowWalkerAdjustment = ability ? romRogueShadowWalkerAdjustment( state.classId, state.secondaryClassId, ability.source, ability.dbcSpellId, rogueShadowWalkerPassiveSpellIds, ) : { allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 }; const rogueCoercePassiveSpellIds = state.classId === "rom-rogue" && state.secondaryClassId === "rom-scout" ? state.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-rogue" && candidate.secondaryClassId === "rom-scout" && candidate.passive && candidate.dbcSpellId === ROM_ROGUE_COERCE_PASSIVE_SPELL_ID && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= state.level) .map((candidate) => candidate.dbcSpellId) : []; const rogueCoerceAdjustment = ability ? romRogueCoerceAbilityAdjustment( state.classId, state.secondaryClassId, ability.source, ability.dbcSpellId, rogueCoercePassiveSpellIds, ) : { allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 }; const flameFollowerCooldownReduction = ability?.source === "runewaker" && ability.dbcSpellId === 490204 && activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.flameFollower)) ? 1_000 : 0; if (ability && (warriorAdjustment.rageCostReduction > 0 || warriorAdjustment.cooldownReductionMs > 0 || warriorDruidWhirlwindAdjustment.rageCostReduction > 0 || warriorDruidWhirlwindAdjustment.cooldownReductionMs > 0 || knightWarriorWhirlwindAdjustment.rageCostReduction > 0 || knightWarriorWhirlwindAdjustment.cooldownReductionMs > 0 || knightCompassionCooldownReductionMs > 0 || rogueShadowWalkerAdjustment.energyCostReduction > 0 || rogueShadowWalkerAdjustment.cooldownIncreaseMs > 0 || rogueCoerceAdjustment.energyCostReduction > 0 || flameFollowerCooldownReduction > 0)) { const rageCostReduction = warriorAdjustment.rageCostReduction + warriorDruidWhirlwindAdjustment.rageCostReduction + knightWarriorWhirlwindAdjustment.rageCostReduction; const adjustedCosts = (ability.costs ?? [ability.cost]).map((cost) => cost.resource === "rage" ? { ...cost, amount: Math.max(0, cost.amount - rageCostReduction) } : cost.resource === "energy" ? { ...cost, amount: Math.max(0, cost.amount - rogueShadowWalkerAdjustment.energyCostReduction - rogueCoerceAdjustment.energyCostReduction) } : cost); ability = { ...ability, cooldownMs: Math.max(0, ability.cooldownMs - warriorAdjustment.cooldownReductionMs - warriorDruidWhirlwindAdjustment.cooldownReductionMs - knightWarriorWhirlwindAdjustment.cooldownReductionMs - knightCompassionCooldownReductionMs - flameFollowerCooldownReduction + rogueShadowWalkerAdjustment.cooldownIncreaseMs), cost: adjustedCosts[0] ?? ability.cost, costs: adjustedCosts, }; } const warlockNightfallAura = state.classId === "warlock" && ability && warlockNightfallConsumerEligible(ability.name) ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WARLOCK_NIGHTFALL_READY_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const warlockNightfallRank = warlockNightfallAura ? warlockTalentRank(state.talentRanks, "Nightfall", 2) : 0; const consumesWarlockNightfall = Boolean(warlockNightfallAura && warlockNightfallRank > 0); if (consumesWarlockNightfall && ability) { const costMultiplier = 1 - warlockNightfallEffectsForRank(warlockNightfallRank).manaCostReductionPercent / 100; const adjustedCosts = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => cost.resource === "mana" ? { ...cost, amount: Math.round(cost.amount * costMultiplier) } : cost); ability = { ...ability, castTimeMs: 0, cost: adjustedCosts[0] ?? ability.cost, costs: adjustedCosts, }; } if (state.classId === "rom-priest" && state.secondaryClassId === "rom-champion" && ability) { const infusedLightPassives = state.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-priest" && candidate.secondaryClassId === "rom-champion" && candidate.passive && learnedAbilityForBuild(candidate, state.level, state.talentRanks) !== null) .map((candidate) => candidate.dbcSpellId); const cooldownOverride = romPriestChampionCooldownOverride(ability, infusedLightPassives); if (cooldownOverride !== null) ability = { ...ability, cooldownMs: cooldownOverride }; } if (ability?.dbcSpellId === 490133 && activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes("rom-whirlwind-mastery"))) { const adjustedCosts = (ability.costs ?? [ability.cost]).map((cost) => cost.resource === "rage" ? { ...cost, amount: Math.max(0, cost.amount - 20) } : cost); ability = { ...ability, cooldownMs: Math.max(0, ability.cooldownMs - 20_000), cost: adjustedCosts[0] ?? ability.cost, costs: adjustedCosts, }; } const innerFocusAura = activePlayerAuras.find((aura) => ( aura.definition.utilityTags?.includes("next-heal-free-critical") )); const innerFocusSerendipityBlocked = Boolean(innerFocusAura && ability && INNER_FOCUS_SERENDIPITY_BLOCKED_SPELLS.has(ability.name)); const consumesInnerFocus = Boolean( innerFocusAura && ability && ["Greater Heal", "Mind Blast", "Power Word: Shield"].includes(ability.name), ); if (consumesInnerFocus && ability && ["Greater Heal", "Mind Blast"].includes(ability.name)) { ability = { ...ability, castTimeMs: Math.round(ability.castTimeMs * 0.5), ...(ability.name === "Mind Blast" ? { effects: [...ability.effects, { kind: "status", status: "slow", durationMs: 5_000, magnitude: 0.7 }], } : {}), }; } const surgeOfLightAura = state.classId === "priest" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light") && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const consumesSurgeOfLight = Boolean(surgeOfLightAura && ["Smite", "Flash Heal"].includes(ability?.name ?? "")); const serendipityAura = state.classId === "priest" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity") && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const serendipityAbility = ["Greater Heal", "Prayer of Healing"].includes(ability?.name ?? ""); const serendipityRank = state.classId === "priest" ? priestTalentRank(state.talentRanks, "Serendipity", 3) : 0; const consumesSerendipity = Boolean(serendipityAura && serendipityRank > 0 && serendipityAbility && !innerFocusSerendipityBlocked); const serendipityReductionPercent = consumesSerendipity ? ([0, 5, 10, 15][serendipityRank] ?? 0) * serendipityAura!.stacks : 0; const hasRuneWakerState = (tag: string) => activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(tag)); const activePlayerAuraTags = activePlayerAuras .filter((aura) => aura.expiresAt === null || aura.expiresAt > now) .flatMap((aura) => aura.definition.utilityTags ?? []); const holyAuraFriendly = Boolean( ability?.source === "runewaker" && ability.name === "Holy Aura" && party.selectedMemberId && hasRuneWakerState("rom-holy-salvation-candle") ); const effectiveTarget = holyAuraFriendly ? "friendly" : ability?.target === "any" ? completingCast ? completingCast.targetId && state.mobs[completingCast.targetId] ? "hostile" : "friendly" : state.selectedFriendlyActorId || party.selectedMemberId || party.selfSelected ? "friendly" : "hostile" : ability?.target; const mageArcaneConcentrationAura = state.classId === "mage" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const mageArcanePotencyAura = state.classId === "mage" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_POTENCY_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const mageBrainFreezeAura = state.classId === "mage" && ability && WOW335_MAGE_BRAIN_FREEZE_CONSUMER_SPELLS.has(ability.name) ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_BRAIN_FREEZE_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const mageMissileBarrageAura = state.classId === "mage" && ability && wow335MageMissileBarrageConsumerEligible(ability.name) ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_MISSILE_BARRAGE_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const consumesMageArcaneConcentration = Boolean( mageArcaneConcentrationAura && ability && !ability.passive, ); const mageArcanePotencyRank = state.classId === "mage" ? mageTalentRank(state.talentRanks, "Arcane Potency", 2) : 0; const mageArcanePotencyProfile = ability ? wow335MageArcanePotencyCastProfile( mageArcanePotencyRank, Boolean(mageArcanePotencyAura), ability.effects.some((effect) => ["damage", "dot", "finisher-damage"].includes(effect.kind)) && abilityAttackKind(state.classId, ability) === "spell", ) : { consumesCharge: false, criticalChanceBonusPercent: 0 }; const mageArcanePotencyCriticalChanceBonusPercent = completingCast?.arcanePotencyCriticalChanceBonusPercent ?? mageArcanePotencyProfile.criticalChanceBonusPercent; const consumesMageArcanePotency = Boolean(!completingCast && mageArcanePotencyAura && ability && !ability.passive && mageArcanePotencyProfile.consumesCharge); const consumesMageBrainFreeze = Boolean(mageBrainFreezeAura && ability); const consumesMageMissileBarrage = Boolean(mageMissileBarrageAura && ability); const druidClearcastingAura = state.classId === "druid" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-druid-clearcasting")) : undefined; const shamanClearcastingAura = state.classId === "shaman" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(SHAMAN_ELEMENTAL_FOCUS_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const consumesShamanClearcasting = Boolean( shamanClearcastingAura && ability && shamanClearcastingEligible(ability), ); const maelstromWeaponRank = state.classId === "shaman" ? shamanTalentRankFromRanks(state.talentRanks, "Maelstrom Weapon", 3) : 0; const maelstromWeaponAura = !completingCast && maelstromWeaponRank > 0 && ability && WOW335_SHAMAN_MAELSTROM_WEAPON_ELIGIBLE_SPELLS.has(ability.name) ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const maelstromWeaponProfile = maelstromWeaponAura ? wow335ShamanMaelstromWeaponProfile(maelstromWeaponRank) : null; const consumesMaelstromWeapon = Boolean(maelstromWeaponAura && maelstromWeaponProfile); const maelstromWeaponRankForCast = completingCast?.maelstromWeaponRank ?? maelstromWeaponRank; const maelstromWeaponStacksConsumed = completingCast?.maelstromWeaponStacksConsumed ?? (consumesMaelstromWeapon && maelstromWeaponAura && maelstromWeaponProfile ? Math.min(maelstromWeaponAura.stacks, maelstromWeaponProfile.maxStacks) : 0); const maelstromWeaponCastTimeReductionPercent = consumesMaelstromWeapon && maelstromWeaponProfile ? wow335ShamanMaelstromWeaponCastTimeReductionPercent(maelstromWeaponProfile, maelstromWeaponAura!.stacks) : 0; const shamanTidalWavesRank = state.classId === "shaman" ? shamanTalentRankFromRanks(state.talentRanks, "Tidal Waves", 3) : 0; const shamanTidalWavesAura = shamanTidalWavesRank > 0 && ability?.source !== "runewaker" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const tidalWavesChargesForCast = completingCast?.tidalWavesChargesConsumed ?? shamanTidalWavesAura?.stacks ?? 0; const consumesTidalWaves = Boolean(!completingCast && shamanTidalWavesAura && ["Healing Wave", "Lesser Healing Wave"].includes(ability?.name ?? "")); const warlockBacklashAura = state.classId === "warlock" && ability && WARLOCK_BACKLASH_SPELLS.has(ability.name) ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WARLOCK_BACKLASH_READY_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const consumesWarlockBacklash = Boolean(warlockBacklashAura); const warlockBacklashRank = consumesWarlockBacklash ? warlockTalentRank(state.talentRanks, "Backlash", 3) : 0; const warlockBackdraftAura = !completingCast && state.classId === "warlock" && ability && warlockBackdraftDestructionSpellEligible(ability.name) ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const warlockBackdraftRank = warlockBackdraftAura ? warlockTalentRank(state.talentRanks, "Backdraft", 3) : 0; const warlockBackdraftEffects = warlockBackdraftRank > 0 ? warlockBackdraftEffectsForRank(warlockBackdraftRank) : null; const consumesWarlockBackdraft = Boolean(warlockBackdraftAura && warlockBackdraftEffects); const consumesDruidClearcasting = Boolean( druidClearcastingAura && ability && druidClearcastingEligible(ability, effectiveTarget ?? ability.target), ); const modifiers: readonly TalentModifier[] = [ ...state.talentModifiers.filter((modifier) => modifier.sourceTalentId !== "talent-1854:active"), ...(consumesInnerFocus && ability?.name === "Greater Heal" ? [{ kind: "critical-chance-percent", amount: 25, unit: "percent", scope: "your Greater Heal spell", sourceTalentId: "talent-348:active", sourceTalentName: "Inner Focus", rank: 1, description: "Inner Focus increases the critical chance of your Greater Heal by 25 percentage points.", } satisfies TalentModifier] : []), ...(consumesMageBrainFreeze ? [{ kind: "damage-percent" as const, amount: 25, unit: "percent" as const, // Keep the scope free of school names: the talent modifier matcher // treats "Frostfire" as a Frost school tag, even on Fireball. // Eligibility is already constrained by the Brain Freeze aura lookup. scope: "your next damage spell", sourceTalentId: "talent-1854:active", sourceTalentName: "Brain Freeze", rank: mageTalentRank(state.talentRanks, "Brain Freeze", 3), description: "Brain Freeze increases this Fireball or Frostfire Bolt's damage by 25%.", }] : []), ...(consumesSerendipity ? (["cast-time-percent", "resource-cost-percent"] as const).map((kind) => ({ kind, amount: -serendipityReductionPercent, unit: "percent" as const, scope: "your next Greater Heal or Prayer of Healing", sourceTalentId: Object.entries(state.talentRanks).find(([nodeId]) => talentNodeById(nodeId)?.name === "Serendipity")?.[0] ?? "talent-serendipity", sourceTalentName: "Serendipity", rank: serendipityRank, description: `Serendipity reduces this cast time and mana cost by ${serendipityReductionPercent}% per active stack.`, })) : []), ...(consumesWarlockBacklash ? [{ kind: "resource-cost-percent" as const, amount: -([0, 25, 50, 50][warlockBacklashRank] ?? 0), unit: "percent" as const, scope: "your spells", sourceTalentId: "talent-1817:active", sourceTalentName: "Backlash", rank: warlockBacklashRank, description: "Backlash reduces the mana cost of your next Shadow Bolt or Incinerate.", }, { kind: "damage-percent" as const, amount: 10, unit: "percent" as const, scope: "your spells", sourceTalentId: "talent-1817:active", sourceTalentName: "Backlash", rank: warlockBacklashRank, description: "Backlash increases the damage of your next Shadow Bolt or Incinerate against monsters by 10%.", }] : []), ]; const warriorFlatRageCostReduction = state.classId === "warrior" && ability?.name === "Heroic Strike" ? Math.min(3, Math.max(0, Math.trunc(state.talentRanks["talent-124"] ?? 0))) * 2 : state.classId === "warrior" && ability?.name === "Execute" ? Math.min(3, Math.max(0, Math.trunc(state.talentRanks["talent-1542"] ?? 0))) * 2 : 0; const costs = [ ...(ability && !consumesInnerFocus && !consumesDruidClearcasting && !consumesSurgeOfLight ? resolvedAbilityCosts(ability, modifiers, { ...state, now }).map((cost) => { if (suddenDoomFreeDeathCoil && cost.resource === "runic-power") return { ...cost, amount: 0 }; if (consumesHunterLockAndLoad && cost.resource === "mana") return { ...cost, amount: 0 }; if (state.classId !== "warrior" || cost.resource !== "rage") return cost; return warriorFlatRageCostReduction > 0 ? { ...cost, amount: Math.max(0, cost.amount - warriorFlatRageCostReduction) } : cost; }) : []), ...(ability ? romBloodArrowCastCosts( ability, { classId: state.classId, secondaryClassId: state.secondaryClassId }, activePlayerAuras, PLAYER_AURA_ENTITY_ID, state.maxHealth, resourceProfileForClass(state.classId).type === "mana" ? state.maxResource : state.maxResourcePools.mana ?? 0, now, ) : []), ]; const resourceCost = ability && !suddenDoomFreeDeathCoil && !consumesInnerFocus && !consumesDruidClearcasting && !consumesSurgeOfLight ? Math.max(0, talentAdjustedResourceCost(ability, modifiers) - (ability.cost.resource === "rage" ? warriorFlatRageCostReduction : 0)) * (consumesHunterLockAndLoad && ability.cost.resource === "mana" ? 0 : 1) : 0; const costsToSpend = completingCast?.mode === "channel" ? [] : costs; const manaSpentForCast = completingCast?.manaSpent ?? costsToSpend.filter((cost) => cost.resource === "mana").reduce((total, cost) => total + cost.amount, 0); const baseTargeting = ability ? targetingRulesForAbility(ability) : null; const targeting = ability?.dbcSpellId === 490457 && baseTargeting && hasRuneWakerState("rom-master-reflected-shot") ? { ...baseTargeting, maximumTargets: 5 } : baseTargeting; const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical"; const consumesShamanNaturesGuardianCrit = Boolean(state.classId === "shaman" && ability && abilityAttackKind(state.classId, ability) === "spell" && ability.effects.some((effect) => effect.kind === "damage" ? (effect.school ?? spellSchool) === "nature" : effect.kind === "heal" && spellSchool === "nature") && wow335ShamanNaturesGuardianNextCritIsReady(activePlayerAuras, PLAYER_AURA_ENTITY_ID, now)); const natureSwiftnessAura = (state.classId === "shaman" || state.classId === "druid") ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-next-nature-instant")) : undefined; const presenceOfMindAura = state.classId === "mage" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-next-mage-instant")) : undefined; const hotStreakAura = state.classId === "mage" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG)) : undefined; const fingersOfFrostAura = state.classId === "mage" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_FINGERS_OF_FROST_TAG)) : undefined; const consumesPresenceOfMind = Boolean( presenceOfMindAura && ability && ability.name !== "Presence of Mind" && ability.castMode !== "channel" && ability.castTimeMs > 0 && ability.castTimeMs < 10_000, ); const consumesHotStreak = Boolean(hotStreakAura && ability?.name === "Pyroblast"); const consumesFingersOfFrost = Boolean(fingersOfFrostAura && ability); const deepFreezeTargetId = request.targetActorId !== undefined ? request.targetActorId : state.selectedTargetId; const deepFreezeTarget = deepFreezeTargetId ? state.mobs[deepFreezeTargetId] : undefined; const deepFreezeTargetIsFrozen = Boolean(deepFreezeTarget?.statuses.some((status) => ( status.kind === "freeze" && status.endsAt > now ))); const consumesNatureSwiftness = Boolean( natureSwiftnessAura && ability && ability.name !== "Nature's Swiftness" && ability.castMode !== "channel" && ability.castTimeMs > 0 && ability.castTimeMs < 10_000 && spellSchool === "nature", ); const runeCost = ability && baseClassFor(state.classId) === "death-knight" ? consumesRimeReady ? null : runeCostForAbility(ability) : null; let failure: CastFailureReason | undefined; if (!ability) failure = "unknown-ability"; else if (!state.abilities.some((entry) => entry.id === ability.id)) failure = "wrong-class"; else if (talentNodeId && !learnedTalentRank) failure = "not-learned"; else if (ability.passive) failure = "passive"; else if (ability.effects.every((effect) => effect.kind === "scripted" || effect.kind === "unsupported")) failure = "unsupported"; else if (ability.source === "runewaker" && ability.classId === "rom-warlock" && ability.secondaryClassId === "rom-mage" && ability.dbcSpellId === 498710 && !romWarlockMageSoulBrandStingHasWillpowerBladeState(activePlayerAuras.filter((aura) => ( aura.expiresAt === null || aura.expiresAt > now )))) failure = "condition-not-met"; else if (state.classId === "warrior" && ability.name === "Victory Rush" && !activePlayerAuras.some((aura) => ( (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)) ))) failure = "condition-not-met"; else if (!isAbilityUnlocked(ability, state.level)) failure = "level-locked"; else if (!romPriestWarriorStanceUseAllowed(ability, activePlayerAuraTags)) failure = "condition-not-met"; else if (state.classId === "warlock" && ability && !warlockMetamorphosisFormActionReady(ability.id, activePlayerAuras.some((aura) => ( aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )))) failure = "condition-not-met"; else if (state.classId === "warlock" && ability && activePlayerAuras.some((aura) => ( aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )) && !warlockMetamorphosisCanCastAbilityWhileActive(ability)) failure = "condition-not-met"; // The source tooltip gives no expiry window; a newer player damage event ends Smash's crit window. else if (ability.source === "runewaker" && ability.dbcSpellId === 490019 && !hasRecentPlayerCriticalHit(state.combatEvents, PLAYER_AGGRO_ID)) failure = "condition-not-met"; else if (state.health <= 0) failure = "player-dead"; else if ( state.controlledUntil > now && enemyControlPreventsActions(state.controlMechanic) && ability?.dbcSpellId !== 498570 && ability?.dbcSpellId !== 1953 && !((state.controlMechanic === "fear" || state.controlMechanic === "stun" || state.controlMechanic === "confuse") && enemyControlAbilityCanEscapeAuras(ability, state.controlMechanic, state.auras, PLAYER_AURA_ENTITY_ID, now)) ) failure = "player-controlled"; else if ( (wow335HunterPreventionTypeForAbility(ability) === 2 || (effectiveTarget === "hostile" && abilityAttackKind(state.classId, ability) === "melee")) && aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID).some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("pacified") )) ) failure = "player-controlled"; else if (state.activeCast && !completingCast) failure = "already-casting"; else if (!completingCast && !suddenDoomFreeDeathCoil && ability.gcdMs > 0 && state.globalCooldownEndsAt > now) failure = "global-cooldown"; else if (!completingCast && !consumesHunterLockAndLoad && (state.cooldowns[abilityId] ?? 0) > now) failure = "cooldown"; else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked"; else if (ability.id === "rom-mage-primary-55" && !hasRuneWakerState("rom-charged")) failure = "condition-not-met"; else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met"; else if (ability.dbcSpellId === 493017 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met"; else if (ability.dbcSpellId === 494037 && !hasRuneWakerState("rom-briar-shield")) failure = "condition-not-met"; else if (state.classId === "rom-champion" && state.secondaryClassId === "rom-warlock" && ability.source === "runewaker" && ability.dbcSpellId === 498600 && state.health / Math.max(1, state.maxHealth) >= 0.65) failure = "condition-not-met"; else if (state.classId === "rom-warden" && ability.source === "runewaker" && ability.target === "hostile" && activeWardenNatureCrystal(state.summons, now)) failure = "condition-not-met"; else if (state.classId === "paladin" && ability.name === "Redemption" && Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met"; else if (abilityRequiresOutOfCombat(ability) && !rogueShadowWalkerAdjustment.allowsCombatUse && !(ability.source === "runewaker" && state.classId === "rom-rogue" && hasRuneWakerState("rom-vanish")) && Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met"; else if (ability.name === "Enraged Regeneration" && !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met"; else if (state.classId === "hunter" && ability.name === "Revive Pet" && !state.summons.some((summon) => summon.health <= 0 && (summon.id.startsWith("player-pet-") || (summon.hunterPet && summon.ownerActorId === PLAYER_AGGRO_ID)))) failure = "condition-not-met"; else if (state.classId === "hunter" && ["Bestial Wrath", "Intimidation", "Kill Command", "Mend Pet"].includes(ability.name) && !activeHunterPetId(state.summons, now)) failure = "condition-not-met"; else if (state.classId === "mage" && ability.name === "Deep Freeze" && !deepFreezeTargetIsFrozen && !fingersOfFrostAura) failure = "condition-not-met"; else if (state.classId === "death-knight" && ability.name === "Blood Tap" && !canActivateDeathKnightBloodTapRune(state.deathKnightRunes, now)) failure = "not-enough-runes"; else if (costsToSpend.some((cost) => cost.resource === "health" ? state.health <= cost.amount : (cost.resource === resourceProfileForClass(state.classId).type ? state.resource : state.resourcePools[cost.resource] ?? 0) < cost.amount)) failure = "not-enough-resource"; else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes"; const playerOrigin = currentOrigin(state, origin); // `null` is a meaningful snapshotted target for friendly casts: it means // the player. Do not fall through to a newly selected ally on completion. let targetId = completingCast ? completingCast.targetId : request.targetActorId !== undefined ? request.targetActorId : effectiveTarget === "hostile" ? state.selectedTargetId : effectiveTarget === "friendly" ? state.selectedFriendlyActorId ?? party.selectedMemberId : null; if (ability?.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID) targetId = null; // Splash selects a destination. A stale, distant or fallen selected actor // must not become a second target requirement for that ground cast. if (ability && isCoaWitchDoctorSplashPotion(ability) && targeting?.shape === "ground") targetId = null; let targetPosition: CombatPosition | null = null; if (!failure && ability && effectiveTarget === "hostile" && targeting?.shape !== "ground") { let target = targetId ? state.mobs[targetId] : null; if (!completingCast && (!target || target.dead) && state.settings.autoTargetNearest) { const maximumRange = talentAdjustedCastRange(ability.range.max, ability, modifiers); const minimumRange = talentAdjustedRange(ability.range.min, ability, modifiers); const eligible = eligibleTargetIds(state, playerOrigin, maximumRange).find((id) => { const distance = Math.sqrt(mobRuntimeDistanceSquared(id, playerOrigin)); return distance >= minimumRange; }); targetId = eligible ?? null; target = targetId ? state.mobs[targetId] : null; } if (!targetId || !target) failure = "no-target"; else if (target.dead) failure = "target-dead"; else { targetPosition = getMobPosition(targetId); if (!targetPosition) failure = "target-unavailable"; else { const distance = Math.sqrt(mobRuntimeDistanceSquared(targetId, playerOrigin)); if (distance < talentAdjustedRange(ability.range.min, ability, modifiers)) failure = "too-close"; else if (distance > talentAdjustedCastRange(ability.range.max, ability, modifiers)) failure = "out-of-range"; } } } else if (!failure && ability && effectiveTarget === "friendly" && targetId) { const partyTarget = party.members.find((member) => member.id === targetId); const target = state.actors[targetId] ?? (partyTarget ? companionActorFromMember(partyTarget, entityAbsorbAmount(state.auras, targetId)) : null); if (!target || !["companion", "pet", "totem", "summon"].includes(target.kind)) failure = "target-unavailable"; else if (!target.alive && !ability.effects.some((effect) => effect.kind === "resurrection")) { failure = "target-dead"; } else { targetPosition = getPartyRuntimePosition(targetId) ?? state.summons.find((summon) => summon.id === targetId)?.position ?? null; if (!targetPosition) failure = "target-unavailable"; else { const distance = Math.sqrt(distanceSquared(playerOrigin, targetPosition)); if (distance < talentAdjustedRange(ability.range.min, ability, modifiers)) failure = "too-close"; else if (distance > talentAdjustedRange(ability.range.max, ability, modifiers)) failure = "out-of-range"; } } } if (!failure && state.classId === "death-knight" && ability?.name === "Death Coil" && effectiveTarget === "friendly") { const partyTarget = targetId ? party.members.find((member) => member.id === targetId) : null; const summon = targetId ? state.summons.find((candidate) => candidate.id === targetId) : null; const friendlyIsUndead = targetId === null ? state.raceId === "undead" : partyTarget?.raceId === "undead" || (summon != null && [26125, 24207].includes(summon.creatureId)); if (!friendlyIsUndead) failure = "condition-not-met"; } if (!failure && ability && targeting?.requiresLineOfSight && targetPosition) { if (!combatHasLineOfSight(playerOrigin, targetPosition)) failure = "line-of-sight"; } if (!failure && ability?.wowWeaponRequirements) { const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, }); const equippedItems = enemyDisarmRequirementCandidates(loadout); if ((ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID && !enemySpellHasMatchingEquippedItem(ability.wowWeaponRequirements, equippedItems)) || enemyDisarmBlocksAbilityCast( state.auras, PLAYER_AURA_ENTITY_ID, now, { ...ability.wowWeaponRequirements, attributes3: ability.wowSpellDefense?.attributes3, passive: ability.passive, createsAura: ability.effects.some((effect) => effect.kind === "apply-aura"), }, equippedItems, )) failure = "condition-not-met"; } const requiredTargetStateTag = ability ? romAbilityRequiredTargetStateTag(ability) : null; if (!failure && requiredTargetStateTag && targetId && !aurasForEntity(state.auras, targetId).some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(requiredTargetStateTag) )) && !state.effects.some((effect) => effect.kind === "dot" && effect.targetId === targetId && effect.remainingTicks > 0 && effect.tag === requiredTargetStateTag)) failure = "condition-not-met"; if (!failure && state.classId === "rom-rogue" && state.secondaryClassId === "rom-mage" && ability?.source === "runewaker" && ability.dbcSpellId === 497206 && !activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(ROM_ROGUE_INSTANT_SHADOW_READY_TAG))) { failure = "condition-not-met"; } const rogueStealthOpeners = new Set(["Backstab", "Ambush", "Garrote", "Sap", "Cheap Shot", "Pick Pocket"]); const shadowDanceOpeners = new Set(["Ambush", "Garrote", "Sap", "Cheap Shot", "Pick Pocket"]); if (!failure && state.classId === "rogue" && ability && rogueStealthOpeners.has(ability.name)) { const hasStealth = activePlayerAuras.some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("stealth") )); const hasShadowDance = shadowDanceOpeners.has(ability.name) && activePlayerAuras.some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("wow335-rogue-shadow-dance") )); if (!hasStealth && !hasShadowDance) failure = "condition-not-met"; } if (!failure && state.classId === "rogue" && ability && ["Backstab", "Ambush"].includes(ability.name)) { const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, }); if (loadout["main-hand"]?.weaponType !== "dagger") failure = "condition-not-met"; } if (!failure && state.classId === "rogue" && ability?.name === "Sap" && targetId && state.mobs[targetId]?.engaged) failure = "condition-not-met"; if (!failure && state.classId === "rogue" && ability?.name === "Pick Pocket" && targetId && state.mobs[targetId]?.engaged) failure = "condition-not-met"; if (!failure && state.classId === "rogue" && ability?.name === "Riposte" && !activePlayerAuras.some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("wow335-rogue-riposte-ready") ))) failure = "condition-not-met"; if (!failure && state.classId === "hunter" && ability?.name === "Counterattack" && !activePlayerAuras.some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready") ))) failure = "condition-not-met"; if (!failure && state.classId === "rogue" && ability?.name === "Tricks of the Trade" && (!targetId || !party.members.some((member) => member.id === targetId))) failure = "no-target"; if (!failure && state.classId === "rogue" && ability?.name === "Killing Spree" && targetId && aurasForEntity(state.auras, targetId).some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.some((tag) => tag === "stealth" || tag === "invisible") ))) failure = "condition-not-met"; if (!failure && state.classId === "warlock" && ability?.name === "Conflagrate" && (!targetId || !activeWarlockConflagrateDot(state.effects, targetId, PLAYER_AGGRO_ID, now))) { failure = "condition-not-met"; } if (!failure && ability?.name === "Kill Shot" && targetId) { const target = state.mobs[targetId]; if (target && target.health / Math.max(1, target.maxHealth) > 0.2) failure = "condition-not-met"; } if (!failure && state.classId === "warrior" && ability?.name === "Execute" && targetId) { const target = state.mobs[targetId]; if (target && target.health / Math.max(1, target.maxHealth) >= 0.2) failure = "condition-not-met"; } if (!failure && !coaBarbarianDecapitateTargetAllowed(ability, targetId ? state.mobs[targetId] : undefined)) { failure = "condition-not-met"; } if (!failure && ability && !coaWitchDoctorCanBrewIngredient( ability, state.auras, PLAYER_AURA_ENTITY_ID, state.talentRanks, now, )) failure = "condition-not-met"; if (!failure && state.classId === "paladin" && ability?.name === "Hammer of Wrath" && targetId) { const target = state.mobs[targetId]; if (target && target.health / Math.max(1, target.maxHealth) > 0.2) failure = "condition-not-met"; } if (!failure && state.classId === "paladin" && ability?.name === "Holy Shield") { const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, }); if (loadout["off-hand"]?.armorType !== "shield") failure = "condition-not-met"; } if (!failure && ability?.source === "runewaker" && ability.dbcSpellId === 490122 && state.health / Math.max(1, state.maxHealth) >= 0.5) failure = "condition-not-met"; if (!failure && ability?.name === "Power Word: Shield" && hasWeakenedSoul(state.auras, targetId ?? PLAYER_AURA_ENTITY_ID, now)) failure = "condition-not-met"; if (!failure && state.classId === "druid" && ability?.name === "Swiftmend") { const swiftmendTargetId = targetId ?? PLAYER_AURA_ENTITY_ID; const hasConsumableHot = state.effects.some((effect) => { if (effect.kind !== "hot" || (effect.targetId ?? PLAYER_AURA_ENTITY_ID) !== swiftmendTargetId || effect.remainingTicks <= 0) return false; const sourceName = abilityById(effect.sourceAbilityId)?.name; return sourceName === "Rejuvenation" || sourceName === "Regrowth"; }); if (!hasConsumableHot) failure = "condition-not-met"; } if (!failure && ability?.source === "runewaker") { const family = targetId ? state.mobs[targetId]?.creatureFamily : undefined; if (state.classId === "rom-rogue" && state.secondaryClassId === "rom-warlock" && ability.dbcSpellId === 498801 && state.health / Math.max(1, state.maxHealth) >= 0.75) failure = "condition-not-met"; if (state.classId === "rom-rogue" && state.secondaryClassId === "rom-knight" && ability.dbcSpellId === 492924 && !hasRuneWakerState("rom-491552")) failure = "condition-not-met"; if (ability.dbcSpellId === 491613 && family !== "undead" && family !== "demon" && !hasRuneWakerState("rom-evil-shackles")) failure = "condition-not-met"; if (ability.dbcSpellId === 492920 && !state.effects.some((effect) => effect.kind === "dot" && effect.targetId === targetId && effect.remainingTicks > 0 && effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")) failure = "condition-not-met"; if ([490151, 499909].includes(ability.dbcSpellId ?? -1) && (!targetId || state.comboTargetId !== targetId || state.comboPoints <= 0)) failure = "condition-not-met"; if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability) || romAbilityNeedsTwoHand(ability) || romAbilityNeedsRangedWeapon(ability)) { const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, }); if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") failure = "condition-not-met"; if (romAbilityNeedsShield(ability) && loadout["off-hand"]?.armorType !== "shield") failure = "condition-not-met"; if (romAbilityNeedsTwoHand(ability) && loadout["main-hand"]?.inventorySlot !== "two-hand") { failure = "condition-not-met"; } if (romAbilityNeedsRangedWeapon(ability) && !loadout.ranged) failure = "condition-not-met"; if (isRomScoutAutoShotAbility(ability) && (!isRomScoutRogueAutoShotOwner(state) || !playerEquippedRangedWeapon(state))) { failure = "condition-not-met"; } } } if (!failure && ability && isRomScoutAutoShotAbility(ability) && activeEnemyDisarmChannels(state.auras, PLAYER_AURA_ENTITY_ID, now).has("ranged")) { failure = "condition-not-met"; } const playerSpatial = combatSpatialActor(PLAYER_AGGRO_ID); const targetSpatial = targetId ? combatSpatialActor(targetId) : null; if (!failure && ability && effectiveTarget === "hostile" && targeting?.requiresFacing && playerSpatial && targetPosition) { if (!isPositionInFacingArc(playerSpatial, targetPosition, targeting.attackArcRadians ?? Math.PI)) { failure = "not-facing"; } } if (!failure && ability && effectiveTarget === "hostile" && targeting?.requiresBehindTarget && targetSpatial) { if (!isSourceBehindTarget(playerOrigin, targetSpatial)) failure = "must-be-behind"; } let groundPosition: CombatPosition | null = completingCast?.groundPosition ?? null; if (!failure && ability && targeting?.shape === "ground") { const requestedGround = request.groundPosition ?? targetPosition ?? playerOrigin; const maximumGroundRange = talentAdjustedCastRange(targeting.groundRange ?? ability.range.max, ability, modifiers); const projected = projectCombatGroundPoint(requestedGround, maximumGroundRange, playerOrigin); if (!projected || Math.sqrt(distanceSquared(playerOrigin, projected)) > maximumGroundRange) failure = "invalid-destination"; else if (targeting.requiresLineOfSight && !combatHasLineOfSight(playerOrigin, projected)) failure = "line-of-sight"; else groundPosition = [projected[0], projected[1], projected[2]]; } const autoAttackTarget = targetId ? state.mobs[targetId] : null; const startsAutoAttack = Boolean( ability && effectiveTarget === "hostile" && abilityAttackKind(state.classId, ability) === "melee" && targetId && autoAttackTarget && !autoAttackTarget.dead && state.abilities.some((entry) => entry.id === ability.id) && !ability.passive && isAbilityUnlocked(ability, state.level) && state.health > 0, ); const startsAutoShot = Boolean( ability && isRomScoutAutoShotAbility(ability) && isRomScoutRogueAutoShotOwner(state) && effectiveTarget === "hostile" && targetId && autoAttackTarget && !autoAttackTarget.dead && state.abilities.some((entry) => entry.id === ability.id) && !ability.passive && isAbilityUnlocked(ability, state.level) && state.health > 0 && playerEquippedRangedWeapon(state), ); const togglesAutoShotOff = startsAutoShot && state.autoShotTargetId === targetId; const autoShotWeapon = startsAutoShot ? playerEquippedRangedWeapon(state) : null; const autoShotIntervalMs = autoShotWeapon ? playerAutoShotProfile(state, autoShotWeapon, activePlayerAuras, now).intervalMs : 0; const autoAttackUpdate = startsAutoAttack && targetId ? { autoAttackTargetId: targetId, nextAutoAttackAt: state.autoAttackTargetId === targetId ? state.nextAutoAttackAt : now, nextOffhandAutoAttackAt: state.autoAttackTargetId === targetId ? state.nextOffhandAutoAttackAt : now, } : {}; const autoShotUpdate = startsAutoShot ? togglesAutoShotOff ? { autoShotTargetId: null, nextAutoShotAt: 0 } : { autoShotTargetId: targetId!, nextAutoShotAt: state.autoShotTargetId ? state.nextAutoShotAt : now + autoShotIntervalMs, autoAttackTargetId: null, nextAutoAttackAt: 0, nextOffhandAutoAttackAt: 0, } : {}; if (failure || !ability) { const reason = failure ?? "unknown-ability"; set({ feedback: nextFeedback("error", castFailureMessage(reason, state.resourceName, ability?.unlockLevel), { abilityId }), ...autoAttackUpdate, ...(completingCast ? romPriestChampionRageControlAwardPatch(state, completingCast, now) : {}), ...(completingCast ? { activeCast: null, playerAnimationEvent: nextAnimationEvent("ability-cancel", abilityId) } : {}), }); if (completingCast) emitPlayerAbilityCancellation(abilityId, now, playerOrigin); return emptyCastResult(reason, abilityId); } let affectedMobIds: string[] = []; if (targeting?.shape === "chain" && effectiveTarget === "hostile" && targetId && targetPosition) { let previous = targetPosition; const ordered = [targetId, ...Object.keys(state.mobs).filter((id) => id !== targetId).sort()]; for (const id of ordered) { if (affectedMobIds.length >= (targeting.maximumTargets ?? 3)) break; const mob = state.mobs[id]; const position = getMobPosition(id); if (!mob || mob.dead || !position) continue; if (affectedMobIds.length > 0 && Math.sqrt(distanceSquared(previous, position)) > (targeting.jumpRange ?? 12)) continue; if (!combatHasLineOfSight(previous, position)) continue; affectedMobIds.push(id); previous = position; } } else if (targeting?.shape === "line" && effectiveTarget === "hostile" && targetId && targetPosition) { const directionX = targetPosition[0] - playerOrigin[0]; const directionZ = targetPosition[2] - playerOrigin[2]; const targetDistance = Math.max(0.001, Math.hypot(directionX, directionZ)); const reach = ability.dbcSpellId === 490239 ? Math.min(targetDistance + 4, ability.range.max) : targetDistance + 4; affectedMobIds = nearbyMobRuntimeIds(playerOrigin, reach).filter((id) => { const mob = state.mobs[id]; const position = getMobPosition(id); if (!mob || mob.dead || !position || !combatHasLineOfSight(playerOrigin, position)) return false; const offsetX = position[0] - playerOrigin[0]; const offsetZ = position[2] - playerOrigin[2]; const forward = (offsetX * directionX + offsetZ * directionZ) / targetDistance; const sideways = Math.abs(offsetX * directionZ - offsetZ * directionX) / targetDistance; return forward >= 0 && forward <= reach && sideways <= 1.25; }); } else if (targeting?.shape === "cone" && (effectiveTarget === "hostile" || effectiveTarget === "self")) { const maximumRange = talentAdjustedRange(targeting.radius ?? ability.radius ?? ability.range.max, ability, modifiers); affectedMobIds = nearbyMobRuntimeIds(playerOrigin, maximumRange).filter((id) => { const mob = state.mobs[id]; const position = getMobPosition(id); return Boolean(mob && !mob.dead && position && combatHasLineOfSight(playerOrigin, position) && (!playerSpatial || isPositionInFacingArc(playerSpatial, position, targeting.attackArcRadians ?? Math.PI / 2))); }); } else if (ability.radius !== undefined || targeting?.shape === "ground") { const center = groundPosition ?? (effectiveTarget === "hostile" ? targetPosition! : playerOrigin); affectedMobIds = nearbyMobRuntimeIds(center, talentAdjustedRange(targeting?.radius ?? ability.radius ?? 0, ability, modifiers)).filter((id) => { const mob = state.mobs[id]; const position = getMobPosition(id); return Boolean(mob && !mob.dead && position && combatHasLineOfSight(center, position)); }); } else if (effectiveTarget === "hostile" && targetId) { affectedMobIds = [targetId]; } if (targeting?.maximumTargets && affectedMobIds.length > targeting.maximumTargets) { affectedMobIds = affectedMobIds.slice(0, targeting.maximumTargets); } const lastPrayerReady = ability.source === "runewaker" && ability.dbcSpellId !== 491648 && ability.castMode !== "channel" && ability.castTimeMs > 0 && hasRuneWakerState("rom-last-prayer"); const hasDruidNaturesGrace = state.classId === "druid" && activePlayerAuras.some((aura) => ( aura.definition.utilityTags?.includes("wow335-druid-natures-grace") && (aura.expiresAt === null || aura.expiresAt > now) )); const hasWarlockEradication = state.classId === "warlock" && activePlayerAuras.some((aura) => ( aura.definition.utilityTags?.includes(WARLOCK_ERADICATION_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )); const eradicationRank = hasWarlockEradication ? warlockTalentRank(state.talentRanks, "Eradication", 3) : 0; const romMageSpellHasteModifiers: readonly TalentModifier[] = state.classId === "rom-mage" ? activePlayerAuras.flatMap((aura) => { if (aura.expiresAt !== null && aura.expiresAt <= now) return []; if (aura.definition.utilityTags?.includes(ROM_DRUID_MAGE_INSPIRATION_AURA_TAG)) return []; const spellHaste = (aura.definition.modifiers ?? []) .filter((modifier) => modifier.kind === "stat" && modifier.stat === "spell-haste" && modifier.operation === "flat") .reduce((total, modifier) => total + modifier.value * (modifier.perStack === false ? 1 : aura.stacks), 0); return spellHaste === 0 ? [] : [{ kind: "haste-percent" as const, amount: spellHaste, unit: "percent" as const, scope: "your spells", sourceTalentId: aura.definition.id, sourceTalentName: aura.definition.name, rank: aura.stacks, description: `${aura.definition.name} modifies your spell haste by ${spellHaste}%.`, }]; }) : []; const romDruidMageInspirationSpellHastePercent = romDruidMageInspirationHastePercent( activePlayerAuras, PLAYER_AURA_ENTITY_ID, now, ); const romDruidMageInspirationSpellHasteModifier: readonly TalentModifier[] = romDruidMageInspirationSpellHastePercent > 0 ? [{ kind: "haste-percent", amount: romDruidMageInspirationSpellHastePercent, unit: "percent", scope: "your spells", sourceTalentId: "rom-druid-mage-inspiration", sourceTalentName: "Inspiration", rank: Math.floor(romDruidMageInspirationSpellHastePercent / 2), description: `Inspiration increases casting speed by ${romDruidMageInspirationSpellHastePercent}%.`, }] : []; const activeNetherwindPresenceHastePercent = state.classId === "mage" ? wow335MageNetherwindPresenceHastePercent(activePlayerAuras, PLAYER_AURA_ENTITY_ID, now) : 0; const wow335MageNetherwindPresenceHasteModifier: readonly TalentModifier[] = activeNetherwindPresenceHastePercent > 0 ? [{ kind: "haste-percent", amount: activeNetherwindPresenceHastePercent, unit: "percent", scope: "your spells", sourceTalentId: WOW335_MAGE_NETHERWIND_PRESENCE_TALENT_ID, sourceTalentName: "Netherwind Presence", rank: mageTalentRank(state.talentRanks, "Netherwind Presence", 3), description: `Netherwind Presence increases spell haste by ${activeNetherwindPresenceHastePercent}%.`, }] : []; const warlockSpellHasteModifier = state.classId === "warlock" && abilityAttackKind(state.classId, ability) === "spell" ? warlockDeathGraspSpellHasteModifier(warlockTalentRank(state.talentRanks, "Death's Grasp", 3)) : null; const castTimeBaseModifiers = warlockSpellHasteModifier ? modifiers.filter((modifier) => modifier.sourceTalentName !== "Death's Grasp") : modifiers; const castTimeModifiers: readonly TalentModifier[] = [ ...castTimeBaseModifiers, ...(warlockSpellHasteModifier ? [warlockSpellHasteModifier] : []), ...(hasDruidNaturesGrace ? [{ kind: "haste-percent" as const, amount: 20, unit: "percent" as const, scope: "your spells, ranged, and melee haste", sourceTalentId: "talent-789:active", sourceTalentName: "Nature's Grace", rank: 1, description: "Nature's Grace increases spell haste by 20%.", }] : []), ...(eradicationRank > 0 ? [{ kind: "haste-percent" as const, amount: [0, 7, 14, 20][eradicationRank] ?? 0, unit: "percent" as const, scope: "your spells", sourceTalentId: "talent-1878:active", sourceTalentName: "Eradication", rank: eradicationRank, description: "Eradication increases spell casting speed for 10 seconds.", }] : []), ...romMageSpellHasteModifiers, ...romDruidMageInspirationSpellHasteModifier, ...wow335MageNetherwindPresenceHasteModifier, ]; const romSniperCastTimeReductionMs = ability.source === "runewaker" && state.classId === "rom-scout" && state.secondaryClassId === "rom-priest" && ability.dbcSpellId === 490450 && activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(ROM_SCOUT_PRIEST_SNIPER_TAG)) ? 1_000 : 0; const ordinaryCastTimeMs = Math.max(0, talentAdjustedCastTime( ability, castTimeModifiers, state.gearStats, state.level, state.classId, state.statRuleset, ) - romSniperCastTimeReductionMs); const tidalWavesAdjustedCastTimeMs = ability.name === "Healing Wave" && tidalWavesChargesForCast > 0 ? Math.max(0, Math.round(ordinaryCastTimeMs * wow335ShamanTidalWavesCastTimeMultiplier(tidalWavesChargesForCast))) : ordinaryCastTimeMs; const missileBarrageProfile = consumesMageMissileBarrage ? wow335MageMissileBarrageProfileForRank(mageTalentRank(state.talentRanks, "Missile Barrage", 3)) : null; const missileBarrageAdjustedCastTimeMs = missileBarrageProfile ? Math.max(0, Math.round(tidalWavesAdjustedCastTimeMs * missileBarrageProfile.channelDurationMultiplier)) : tidalWavesAdjustedCastTimeMs; const maelstromAdjustedCastTimeMs = Math.max(0, Math.round( missileBarrageAdjustedCastTimeMs * (1 - maelstromWeaponCastTimeReductionPercent / 100), )); const castTimeBeforeBackdraftMs = consumesPresenceOfMind || consumesHotStreak || consumesMageBrainFreeze || consumesNatureSwiftness || consumesSurgeOfLight || consumesWarlockBacklash ? 0 : lastPrayerReady ? Math.min(1_000, maelstromAdjustedCastTimeMs) : maelstromAdjustedCastTimeMs; const castTimeMs = warlockBackdraftEffects ? Math.max(0, Math.round(castTimeBeforeBackdraftMs * (1 - warlockBackdraftEffects.reductionPercent / 100))) : castTimeBeforeBackdraftMs; const gcdBeforeBackdraftMs = talentAdjustedGcd( ability, castTimeModifiers, state.gearStats, state.level, state.classId, state.statRuleset, ); const gcdMs = warlockBackdraftEffects && gcdBeforeBackdraftMs > 0 ? Math.max(1_000, Math.round(gcdBeforeBackdraftMs * (1 - warlockBackdraftEffects.reductionPercent / 100))) : gcdBeforeBackdraftMs; const improvedRuneTapRank = state.classId === "death-knight" && ability.name === "Rune Tap" ? Math.max(0, Math.min(3, Math.trunc(state.talentRanks["talent-1942"] ?? 0))) : 0; const improvedWhirlwindRank = state.classId === "warrior" && ability.name === "Whirlwind" ? Math.max(0, Math.min(2, Math.trunc(state.talentRanks["talent-1655"] ?? 0))) : 0; const reverberationCooldownReductionMs = state.classId === "shaman" && ability.cooldownGroup === "shaman-shock" ? shamanTalentRankFromRanks(state.talentRanks, "Reverberation", 3) * 500 : 0; const romMageChampionCooldownReductionMs = romMageCooldownReductionMs( state.classId, state.secondaryClassId, ability, state.level, state.abilities, ); const energizedHandsCooldownReductionMs = romRogueEnergizedHandsActive( activePlayerAuras, PLAYER_AURA_ENTITY_ID, now, ) ? ROM_ROGUE_ENERGIZED_HANDS_COOLDOWN_REDUCTION_MS : 0; const cooldownMs = consumesHunterLockAndLoad ? 0 : Math.max(0, talentAdjustedCooldown(ability, modifiers) - improvedRuneTapRank * 10_000 - improvedWhirlwindRank * 1_000 - reverberationCooldownReductionMs - romMageChampionCooldownReductionMs - energizedHandsCooldownReductionMs); const activeResourceType = resourceProfileForClass(state.classId).type; const spentPools: Partial> = { ...state.resourcePools, [activeResourceType]: state.resource, }; const lastResourceSpentAt = { ...state.lastResourceSpentAt }; let healthAfterCosts = state.health; for (const cost of costsToSpend) { if (cost.resource === "health") healthAfterCosts = Math.max(1, healthAfterCosts - cost.amount); else { spentPools[cost.resource] = Math.max(0, (spentPools[cost.resource] ?? 0) - cost.amount); if (cost.amount > 0) lastResourceSpentAt[cost.resource] = now; } } const romDruidMageInspirationLearned = isRomDruidMagePair(state) && state.abilities.some((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-druid" && candidate.secondaryClassId === "rom-mage" && candidate.passive && candidate.dbcSpellId === ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID && learnedAbilityForBuild(candidate, state.level, state.talentRanks) !== null); const applyRomDruidMageInspirationSpend = (spentNaturePower: number): void => { if (!romDruidMageInspirationLearned || !(spentNaturePower > 0)) return; const candidates = [ { id: PLAYER_AURA_ENTITY_ID, position: playerOrigin, living: work.health > 0, }, ...usePartyStore.getState().members.map((member) => ({ id: member.id, position: getPartyRuntimePosition(member.id), living: member.health > 0, })), ]; const recipients = romDruidMageInspirationRecipientIds(playerOrigin, candidates); work.auras = [...romDruidMageInspirationApplySpendStack( work.auras, PLAYER_AURA_ENTITY_ID, recipients, now, )]; }; applyRomDruidMageInspirationSpend(costsToSpend .filter((cost) => cost.resource === "nature-power" && cost.amount > 0) .reduce((total, cost) => total + cost.amount, 0)); const spentRunes = runeCost && completingCast?.mode !== "channel" ? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now) : state.deathKnightRunes; const spentDeathKnightRuneSlots = state.classId === "death-knight" && spentRunes ? wow335DeathKnightSpentRuneSlots(state.deathKnightRunes, spentRunes, now) : Object.freeze([] as number[]); const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost; if (!completingCast && castTimeMs > 0) { castSequence += 1; const activeCast: ActiveCast = { id: castSequence, abilityId: ability.id, targetId, mode: ability.castMode === "channel" ? "channel" : "cast", startedAt: now, completesAt: now + castTimeMs, originalDurationMs: castTimeMs, pushbackHits: 0, school: spellSchool, canCastWhileMoving: canAbilityCastWhileMoving(ability) || (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")) || romMageWarriorMeditationPathAllowsMovement( state.classId, state.secondaryClassId, romMageWarriorMeditationPathPassiveSpellIds.includes(ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID), activePlayerAuras, PLAYER_AURA_ENTITY_ID, now, ), origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]], groundPosition, totalTicks: ability.castMode === "channel" ? channelPulseCount(ability) : 0, ticksCompleted: 0, nextTickAt: ability.castMode === "channel" ? channelPulseAt(ability, now, castTimeMs, 0) : 0, ...(consumesTidalWaves ? { tidalWavesChargesConsumed: tidalWavesChargesForCast } : {}), ...(consumesMaelstromWeapon ? { maelstromWeaponRank, maelstromWeaponStacksConsumed, } : {}), ...(mageArcanePotencyCriticalChanceBonusPercent > 0 ? { arcanePotencyCriticalChanceBonusPercent: mageArcanePotencyCriticalChanceBonusPercent } : {}), ...(state.classId === "mage" ? { manaSpent: manaSpentForCast } : {}), ...(state.classId === "paladin" ? { illuminationBaseManaCost: abilityAtLevel(abilityById(ability.id) ?? ability, state.level).cost.amount } : {}), }; const channelCooldowns: Record = { ...state.cooldowns }; if (activeCast.mode === "channel" && cooldownMs > 0) { channelCooldowns[ability.id] = now + cooldownMs; } const castStartAuras = consumesWarlockBackdraft && warlockBackdraftAura ? consumeWarlockBackdraftCharge(state.auras, warlockBackdraftAura.instanceId) : state.auras; const castStartAurasAfterOtherConsumes = castStartAuras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && (aura.definition.utilityTags?.includes("rom-last-prayer") || (consumesWarlockBacklash && aura.instanceId === warlockBacklashAura?.instanceId) || (consumesMaelstromWeapon && aura.instanceId === maelstromWeaponAura?.instanceId) || (activeCast.mode === "channel" && consumesMageArcaneConcentration && aura.instanceId === mageArcaneConcentrationAura?.instanceId) || (activeCast.mode === "channel" && consumesMageMissileBarrage && aura.instanceId === mageMissileBarrageAura?.instanceId) || (consumesMageArcanePotency && aura.instanceId === mageArcanePotencyAura?.instanceId)) )); const castStartAurasAfterTidalWaves = consumesTidalWaves && shamanTidalWavesAura ? consumeShamanTidalWavesChargeInAuras(castStartAurasAfterOtherConsumes, shamanTidalWavesAura.instanceId) : castStartAurasAfterOtherConsumes; const bladeBarrierRank = state.talentRanks[WOW335_DEATH_KNIGHT_BLADE_BARRIER_TALENT_ID] ?? 0; const bladeBarrierDefinition = activeCast.mode === "channel" && state.classId === "death-knight" && spentRunes && wow335DeathKnightBladeBarrierShouldActivate( bladeBarrierRank, state.deathKnightRunes, spentRunes, now, ) ? wow335DeathKnightBladeBarrierAuraDefinition(bladeBarrierRank) : null; const castStartAurasAfterBladeBarrier = bladeBarrierDefinition ? applyCombatAura(castStartAurasAfterTidalWaves, { definition: bladeBarrierDefinition, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras : castStartAurasAfterTidalWaves; const castStartAurasAfterNetherwindPresence = state.classId === "mage" ? wow335MageNetherwindPresenceAurasAfterCast( castStartAurasAfterBladeBarrier, PLAYER_AURA_ENTITY_ID, PLAYER_AURA_ENTITY_ID, state.talentRanks[WOW335_MAGE_NETHERWIND_PRESENCE_TALENT_ID] ?? 0, ability.name, now, ) : castStartAurasAfterBladeBarrier; const runePulseUseEffect = !completingCast ? romChampionRunePulseUseEffect( ability, castStartAurasAfterNetherwindPresence, PLAYER_AURA_ENTITY_ID, now, ) : null; const castStartAurasAfterRunePulse = runePulseUseEffect ? applyCombatAura(castStartAurasAfterNetherwindPresence, { definition: runePulseUseEffect.aura, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras : castStartAurasAfterNetherwindPresence; set({ activeCast, ...(lastPrayerReady || consumesWarlockBacklash || consumesMaelstromWeapon || consumesTidalWaves || consumesWarlockBackdraft || consumesMageArcanePotency || Boolean(bladeBarrierDefinition) || castStartAurasAfterRunePulse !== castStartAurasAfterBladeBarrier || (activeCast.mode === "channel" && (consumesMageArcaneConcentration || consumesMageMissileBarrage)) ? { auras: castStartAurasAfterRunePulse } : {}), ...autoAttackUpdate, playerAnimationEvent: nextAnimationEvent("ability-start", ability.id), health: activeCast.mode === "channel" ? healthAfterCosts : state.health, resource: activeCast.mode === "channel" ? activeResourceAfterCosts : state.resource, resourcePools: activeCast.mode === "channel" ? spentPools : state.resourcePools, lastResourceSpentAt: activeCast.mode === "channel" ? lastResourceSpentAt : state.lastResourceSpentAt, deathKnightRunes: activeCast.mode === "channel" && spentRunes ? spentRunes : state.deathKnightRunes, cooldowns: channelCooldowns, combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "cast", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: targetId ?? PLAYER_AURA_ENTITY_ID, abilityId: ability.id, school: spellSchool, rawAmount: 0, effectiveAmount: 0, overheal: 0, absorbed: 0, critical: false, outcome: "started", }), globalCooldownEndsAt: gcdMs > 0 ? now + gcdMs : state.globalCooldownEndsAt, selectedTargetId: effectiveTarget === "hostile" ? targetId : state.selectedTargetId, feedback: nextFeedback( "success", `${activeCast.mode === "channel" ? "Channeling" : "Casting"} ${ability.name}...`, { abilityId: ability.id, targetId: targetId ?? undefined }, ), }); emitPlayerAbilityPresentation( ability, "cast-start", now, playerOrigin, targetId, targetPosition, ); return { ...emptyCastResult(), ok: true, abilityId: ability.id }; } const progression = progressionFromState(state); const work: MutableCastWork = { classId: state.classId, statRuleset: state.statRuleset, gender: state.gender, abilities: state.abilities, mobs: { ...state.mobs }, effects: [...state.effects], auras: [...state.auras], summons: [...state.summons], areaEffects: [...state.areaEffects], combatEvents: [...state.combatEvents], romPriestHolyCandleLastChargeAt: { ...state.romPriestHolyCandleLastChargeAt }, actors: { ...combatActorSnapshots(state) }, cooldowns: { ...state.cooldowns }, health: healthAfterCosts, maxHealth: state.maxHealth, shield: state.shield, shieldExpiresAt: state.shieldExpiresAt, resource: activeResourceAfterCosts, maxResource: state.maxResource, resourcePools: spentPools, maxResourcePools: { ...state.maxResourcePools }, lastResourceSpentAt, comboPoints: state.comboPoints, comboTargetId: state.comboTargetId, deathKnightRunes: spentRunes ?? state.deathKnightRunes, progression, totalDamage: 0, totalHealing: 0, combatResults: [], talentRanks: state.talentRanks, talentModifiers: modifiers, gearStats: state.gearStats, raceId: state.raceId, secondaryClassId: state.secondaryClassId, }; const runePulseUseEffect = !completingCast ? romChampionRunePulseUseEffect(ability, work.auras, PLAYER_AURA_ENTITY_ID, now) : null; if (runePulseUseEffect) { work.auras = [...applyCombatAura(work.auras, { definition: runePulseUseEffect.aura, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } if (state.classId === "mage" && !completingCast) { work.auras = [...wow335MageNetherwindPresenceAurasAfterCast( work.auras, PLAYER_AURA_ENTITY_ID, PLAYER_AURA_ENTITY_ID, state.talentRanks[WOW335_MAGE_NETHERWIND_PRESENCE_TALENT_ID] ?? 0, ability.name, now, )]; } if (state.classId === "death-knight" && spentRunes && wow335DeathKnightBladeBarrierShouldActivate( state.talentRanks[WOW335_DEATH_KNIGHT_BLADE_BARRIER_TALENT_ID] ?? 0, state.deathKnightRunes, spentRunes, now, )) { const definition = wow335DeathKnightBladeBarrierAuraDefinition( state.talentRanks[WOW335_DEATH_KNIGHT_BLADE_BARRIER_TALENT_ID] ?? 0, ); if (definition) { work.auras = [...applyCombatAura(work.auras, { definition, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } if (consumesRimeReady && rimeReadyAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== rimeReadyAura.instanceId); } if (consumesHunterLockAndLoad && hunterLockAndLoadAura) { work.auras = [...consumeLockAndLoadCharge(work.auras, hunterLockAndLoadAura.instanceId)]; } if (state.classId === "rom-rogue" && state.secondaryClassId === "rom-mage" && ability.dbcSpellId === 497206) { work.auras = work.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(ROM_ROGUE_INSTANT_SHADOW_READY_TAG) )); } if (consumesWarlockBacklash && warlockBacklashAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== warlockBacklashAura.instanceId); } if (consumesWarlockBackdraft && warlockBackdraftAura) { work.auras = consumeWarlockBackdraftCharge(work.auras, warlockBackdraftAura.instanceId); } work.auras = maybeApplyWarlockBackdraft(work.auras, state.classId, work.talentRanks, ability.name, now); if (consumesMaelstromWeapon && maelstromWeaponAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== maelstromWeaponAura.instanceId); } if (consumesTidalWaves && shamanTidalWavesAura) { work.auras = consumeShamanTidalWavesChargeInAuras(work.auras, shamanTidalWavesAura.instanceId); } let priestSerendipityTriggered = false; let improvedKickInterruptLanded = false; if (consumesInnerFocus && innerFocusAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== innerFocusAura.instanceId); } if (consumesSurgeOfLight && surgeOfLightAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== surgeOfLightAura.instanceId); } if (consumesSerendipity && serendipityAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== serendipityAura.instanceId); } if (consumesDruidClearcasting && druidClearcastingAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== druidClearcastingAura.instanceId); } if (consumesMageArcaneConcentration && mageArcaneConcentrationAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== mageArcaneConcentrationAura.instanceId); } if (consumesMageArcanePotency && mageArcanePotencyAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== mageArcanePotencyAura.instanceId); } if (consumesMageBrainFreeze && mageBrainFreezeAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== mageBrainFreezeAura.instanceId); } if (consumesMageMissileBarrage && mageMissileBarrageAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== mageMissileBarrageAura.instanceId); } if (consumesShamanClearcasting && shamanClearcastingAura) { work.auras = work.auras.flatMap((aura) => aura.instanceId !== shamanClearcastingAura.instanceId ? [aura] : aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []); } if (consumesNatureSwiftness && natureSwiftnessAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== natureSwiftnessAura.instanceId); } if (consumesPresenceOfMind && presenceOfMindAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== presenceOfMindAura.instanceId); } if (consumesHotStreak && hotStreakAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== hotStreakAura.instanceId); } if (consumesFingersOfFrost && fingersOfFrostAura) { work.auras = work.auras.flatMap((aura) => aura.instanceId !== fingersOfFrostAura.instanceId ? [aura] : aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []); } if (ability.name === "Enraged Regeneration") { work.auras = work.auras.filter((aura) => !consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now)); } const baseCastThreatSource = playerThreatSource(state, ability.id); const castThreatSource = { ...baseCastThreatSource, threatScale: (baseCastThreatSource.threatScale ?? 1) * romWarriorThreatMultiplier(ability, romWarriorPassiveSpellIds), }; const selfPlacedHunterTrap = state.classId === "hunter" && ["Freezing Trap", "Frost Trap", "Immolation Trap"].includes(ability.name); if (((targeting?.shape === "ground" && groundPosition) || selfPlacedHunterTrap) && completingCast?.mode !== "channel") { const hunterTrapKind = state.classId === "hunter" && ability.name === "Freezing Arrow" ? "freezing" : state.classId === "hunter" && ability.name === "Freezing Trap" ? "freezing" : state.classId === "hunter" && ability.name === "Frost Trap" ? "frost" : state.classId === "hunter" && ability.name === "Immolation Trap" ? "immolation" : state.classId === "hunter" && ability.name === "Explosive Trap" ? "explosive" : null; if (hunterTrapKind) { areaEffectSequence += 1; const trapPosition = groundPosition ?? state.playerPosition; const trapDurationMs = ability.effects.find((effect) => effect.kind === "utility")?.durationMs || 30_000; const directDamageScaling = hunterTrapKind === "explosive" ? hunterExplosiveTrapDirectScaling(ability.dbcSpellId) ?? undefined : undefined; const replacedHunterTraps = work.areaEffects.filter((area) => ( area.sourceActorId === PLAYER_AGGRO_ID && area.hunterTrap )); for (const previous of replacedHunterTraps) { if (previous.hunterTrap?.kind !== "frost-zone") continue; const previousSlowSourceId = `hunter-trap-zone:${previous.id}`; for (const [id, mob] of Object.entries(work.mobs)) { if (!mob.statuses.some((status) => status.sourceAbilityId === previousSlowSourceId)) continue; work.mobs[id] = { ...mob, statuses: mob.statuses.filter((status) => status.sourceAbilityId !== previousSlowSourceId), }; } } work.areaEffects = work.areaEffects.filter((area) => !( area.sourceActorId === PLAYER_AGGRO_ID && area.hunterTrap )); work.areaEffects.push({ id: `hunter-trap-${areaEffectSequence}`, sourceActorId: PLAYER_AGGRO_ID, sourceAbilityId: ability.id, position: trapPosition, radius: 2, createdAt: now, expiresAt: now + trapDurationMs, tickIntervalMs: 0, nextTickAt: Number.MAX_SAFE_INTEGER, hunterTrap: { kind: hunterTrapKind, triggerRadius: 2, ...(hunterTrapKind === "freezing" ? { freezeDurationMs: freezingTrapDurationMs(ability.dbcSpellId) } : {}), ...(directDamageScaling ? { directDamageScaling } : {}), }, }); } else if (targeting?.shape === "ground" && groundPosition) { const periodic = ability.effects.filter((effect) => effect.kind === "dot" || effect.kind === "hot"); const durationMs = periodic.reduce((maximum, effect) => ( Math.max(maximum, effect.ticks * effect.intervalMs) ), 0); if (durationMs > 0) { areaEffectSequence += 1; work.areaEffects.push({ id: `area-effect-${areaEffectSequence}`, sourceActorId: PLAYER_AGGRO_ID, sourceAbilityId: ability.id, position: groundPosition, radius: talentAdjustedRange(targeting.radius ?? ability.radius ?? 8, ability, modifiers), createdAt: now, expiresAt: now + durationMs, tickIntervalMs: Math.min(...periodic.map((effect) => effect.intervalMs)), nextTickAt: now + Math.min(...periodic.map((effect) => effect.intervalMs)), }); } } } const hasHostileEffect = (ability.target !== "any" || effectiveTarget === "hostile") && ability.effects.some((effect) => ( effect.kind === "damage" || effect.kind === "finisher-damage" || effect.kind === "dot" || effect.kind === "taunt" || effect.kind === "status" || (effect.kind === "apply-aura" && effect.aura.disposition === "debuff") || effect.kind === "interrupt" || effect.kind === "pull" || effect.kind === "execute" || effect.kind === "resource-drain" || effect.kind === "threat" )); if (hasHostileEffect) { work.auras = work.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("stealth") )); } if (hasHostileEffect) { for (const id of affectedMobIds) engageMobGroupInWork(work, id, now, castThreatSource.actorId); } const damageSchool = abilityDamageSchool(state.classId, ability); const attackKind = abilityAttackKind(state.classId, ability); let playerPositionAfterEffects: CombatPosition = state.playerPosition; let playerRelocationAfterEffects: { readonly position: CombatPosition; readonly yaw: number } | null = null; let clearedPlayerControl = ability.name === "Vanish" && state.controlledUntil > now && (state.controlMechanic === "root" || state.controlMechanic === "slow"); let survivingDispelPlayerControl: ReturnType = null; const cooldownAdjustments: Array> = []; const romMainHand = ability.source === "runewaker" ? resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, })["main-hand"] : null; const romCastTargetTags = work.auras.filter((aura) => aura.targetId === targetId) .flatMap((aura) => aura.definition.utilityTags ?? []).concat( romCreatureFamilyTargetTags(targetId && work.mobs[targetId] ? work.mobs[targetId].creatureFamily : "unknown"), work.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0) .flatMap((effect) => [ ...(effect.tag ? [effect.tag] : []), ...(effect.tag?.includes("501690") ? ["rom-501690"] : []), ...(effect.tag?.includes("rom-deadly-poison") ? ["rom-deadly-poison"] : []), ]), ); const selectedRomHealingMember = targetId ? party.members.find((member) => member.id === targetId) : null; const selectedRomHealingSummon = targetId ? work.summons.find((summon) => summon.id === targetId) : null; const selectedRomOnlineFriendly = targetId ? onlinePlayerRuntimeActors().find((actor) => actor.id === targetId) : null; const selectedRomHealingActor = targetId ? work.actors[targetId] : null; const romFriendlyHealth = !targetId || targetId === PLAYER_AURA_ENTITY_ID ? work.health : selectedRomHealingMember?.health ?? selectedRomHealingSummon?.health ?? selectedRomOnlineFriendly?.health ?? selectedRomHealingActor?.health ?? null; const romFriendlyMaxHealth = !targetId || targetId === PLAYER_AURA_ENTITY_ID ? work.maxHealth : selectedRomHealingMember?.maxHealth ?? selectedRomHealingSummon?.maxHealth ?? selectedRomOnlineFriendly?.maxHealth ?? selectedRomHealingActor?.maxHealth ?? null; const learnedRomPriestPassiveSpellIds = state.classId === "rom-priest" && state.secondaryClassId === "rom-scout" ? work.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-priest" && candidate.secondaryClassId === "rom-scout" && candidate.passive && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= work.progression.level) .map((candidate) => candidate.dbcSpellId) : []; const learnedRomPriestChampionPassiveSpellIds = state.classId === "rom-priest" && state.secondaryClassId === "rom-champion" ? work.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-priest" && candidate.secondaryClassId === "rom-champion" && candidate.passive && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= work.progression.level) .map((candidate) => candidate.dbcSpellId) : []; const learnedRomPriestWarriorCondensedRageSpellIds = state.classId === "rom-priest" && state.secondaryClassId === "rom-warrior" && work.abilities.some((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-priest" && candidate.secondaryClassId === "rom-warrior" && candidate.dbcSpellId === 493038 && learnedAbilityForBuild(candidate, work.progression.level, state.talentRanks) !== null) ? [493038] : []; const learnedRomWarlockPassiveSpellIds = state.classId === "rom-warlock" && state.secondaryClassId ? work.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-warlock" && candidate.secondaryClassId === state.secondaryClassId && candidate.passive && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= work.progression.level) .map((candidate) => candidate.dbcSpellId) : []; const translatedCastEffects = romEffectsForCast(ability, { sealStacks: targetId && state.comboTargetId === targetId ? state.comboPoints : 0, bleedCount: romTargetBleedCount(state.effects, PLAYER_AGGRO_ID, targetId), charged: hasRuneWakerState("rom-charged"), mainHandDagger: romMainHand?.weaponType === "dagger", mainHandTwoHandHammer: romMainHand?.inventorySlot === "two-hand" && romMainHand.weaponType === "mace", casterMaxHealth: state.maxHealth, targetHealthPercent: targetId && state.mobs[targetId] ? state.mobs[targetId]!.health / Math.max(1, state.mobs[targetId]!.maxHealth) : 1, friendlyTargetHealthPercent: effectiveTarget === "friendly" && romFriendlyHealth !== null && romFriendlyMaxHealth !== null ? romFriendlyHealth / Math.max(1, romFriendlyMaxHealth) : 1, selectedTargetRelationship: effectiveTarget === "friendly" ? "friendly" : "hostile", slashBleed: romTargetHasSlashBleed(work.effects, targetId), naturePower: state.resourcePools["nature-power"] ?? 0, defaultDamageIsPhysical: damageSchool === "physical", learnedPassiveSpellIds: [ ...romWarriorPassiveSpellIds, ...learnedRomPriestPassiveSpellIds, ...learnedRomPriestChampionPassiveSpellIds, ...learnedRomPriestWarriorCondensedRageSpellIds, ...learnedRomWarlockPassiveSpellIds, ], passiveTags: work.auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID) .flatMap((aura) => aura.definition.utilityTags ?? []), eruptionProcRoll: stableCombatUnit( `${PLAYER_AGGRO_ID}:${ability.id}:${targetId ?? "self"}:${now}:rom-eruption`, ), targetTags: romCastTargetTags, }); let castEffects = isRomScoutAutoShotAbility(ability) ? [] : state.classId === "rom-knight" && state.secondaryClassId === "rom-priest" ? romPriestKnightConditionalCastEffects( ability, translatedCastEffects, targetId && state.comboTargetId === targetId ? state.comboPoints : 0, !targetId || targetId === PLAYER_AURA_ENTITY_ID, ) : translatedCastEffects; if (state.classId === "rom-warlock" && ability.source === "runewaker" && ability.dbcSpellId === 498645 && state.secondaryClassId === "rom-warrior") { const rageConsumed = work.resourcePools.rage ?? 0; const addedDurationMs = Math.floor(rageConsumed / 20) * 1_000; castEffects = castEffects.map((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken") ? { ...effect, aura: { ...effect.aura, durationMs: (effect.aura.durationMs ?? 0) + addedDurationMs }, } : effect); work.resourcePools = { ...work.resourcePools, rage: 0 }; } if (state.classId === "mage") { const empoweredFireRank = state.talentRanks[WOW335_MAGE_EMPOWERED_FIRE_TALENT_ID] ?? 0; castEffects = castEffects.map((effect) => wow335MageEmpoweredFireEffect(ability.name, effect, empoweredFireRank)); } if (state.classId === "death-knight") { const epidemicRank = state.talentRanks[WOW335_DEATH_KNIGHT_EPIDEMIC_TALENT_ID] ?? 0; castEffects = castEffects.map((effect) => wow335DeathKnightEpidemicDotEffect(effect, epidemicRank)); } const warlockStateRefreshTags = romWarlockStateRefreshTags(ability, romCastTargetTags); if (targetId && warlockStateRefreshTags.length) { const refreshTagSet = new Set(warlockStateRefreshTags); work.auras = work.auras.map((aura) => aura.targetId === targetId && aura.definition.utilityTags?.some((tag) => refreshTagSet.has(tag)) ? { ...aura, expiresAt: now + (aura.definition.durationMs ?? 15_000) } : aura); work.effects = work.effects.map((effect) => { if (effect.kind !== "dot" || effect.targetId !== targetId || !effect.tag || !refreshTagSet.has(effect.tag)) return effect; const sourceAbility = work.abilities.find((candidate) => candidate.id === effect.sourceAbilityId); const sourceDot = sourceAbility?.effects.find((candidate) => candidate.kind === "dot" && candidate.tag === effect.tag); return sourceDot?.kind === "dot" ? { ...effect, remainingTicks: sourceDot.ticks, nextTickAt: now + sourceDot.intervalMs } : effect; }); } if (ability.dbcSpellId === 492920 && targetId && castEffects.some((effect) => effect.kind === "dot" && effect.tag === "rom-622992")) { work.effects = work.effects.filter((effect) => !(effect.kind === "dot" && effect.targetId === targetId && effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")); } if (state.classId === "rom-warrior" && ability.dbcSpellId === 492332 && romWarriorPassiveSpellIds.includes(492912)) { work.auras = [...applyCombatAura(work.auras, { definition: { id: "rom-warrior:blood-retention", name: "Blood Retention", disposition: "buff", durationMs: 10_000, modifiers: [{ kind: "resource", resource: "rage", property: "regeneration", operation: "flat", value: 1.5, }], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } const effectsToResolve = completingCast?.mode === "channel" ? castEffects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind)) : castEffects; const holyBlessingSpellPowerBonus = state.classId === "rom-priest" && state.statRuleset !== "rom" ? romPriestHolyBlessingStats( playerCharacterStats( state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, modifiers, state.gender, state.statRuleset, ), aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID, now), ).spellPowerBonus : 0; const playerWeaponChannel = ability.wowSpellDefense ? enemyDisarmAbilityWeaponChannel({ ...ability.wowSpellDefense, equippedItemSubclassMask: ability.wowWeaponRequirements?.equippedItemSubclassMask, }) : undefined; const playerForm = state.classId === "druid" ? wow335DruidShapeshiftFormForAuras(work.auras, PLAYER_AURA_ENTITY_ID, now) : undefined; const playerDisabledWeaponChannels = playerForm === "cat" || playerForm === "bear" ? new Set() : activeEnemyDisarmChannels(work.auras, PLAYER_AURA_ENTITY_ID, now); const playerEffectiveStats = applyAuraStats( playerCharacterStats( state.classId, work.progression.level, work.gearStats, state.raceId, state.secondaryClassId, modifiers, state.gender, state.statRuleset, ), effectiveActorAuras(work.auras, PLAYER_AURA_ENTITY_ID, now, playerWeaponChannel), ); const playerCurrentIntellect = playerEffectiveStats.intellect; const divineFavorReady = state.classId === "paladin" && ["Flash of Light", "Holy Light", "Holy Shock"].includes(ability.name) && work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal") && (aura.expiresAt === null || aura.expiresAt > now)); const curingShotRecipients = ability.source === "runewaker" && ability.classId === "rom-priest" && ability.secondaryClassId === "rom-scout" && ability.dbcSpellId === 492636 ? (() => { const partyMembers = usePartyStore.getState().members.filter((member) => member.health > 0); const friendlySummons = work.summons.filter((summon) => summon.health > 0); const onlineFriendlies = onlinePlayerRuntimeActors().filter((actor) => actor.health > 0); const candidates = [...new Set([ PLAYER_AURA_ENTITY_ID, ...partyMembers.map((member) => member.id), ...friendlySummons.map((summon) => summon.id), ...onlineFriendlies.map((actor) => actor.id), ])]; const positionFor = (id: string): CombatPosition | null => id === PLAYER_AURA_ENTITY_ID ? playerOrigin : getPartyRuntimePosition(id) ?? friendlySummons.find((summon) => summon.id === id)?.position ?? null; return candidates .filter((id) => { const position = positionFor(id); return Boolean(position && Math.sqrt(distanceSquared(playerOrigin, position)) <= (targeting?.radius ?? 20) && combatHasLineOfSight(playerOrigin, position)); }) .sort((left, right) => Number(right === targetId) - Number(left === targetId) || left.localeCompare(right)) .slice(0, targeting?.maximumTargets ?? candidates.length); })() : null; const areaFriendlyRecipients = targeting?.shape === "circle" && effectiveTarget !== "hostile" ? (() => { const partyMembers = usePartyStore.getState().members.filter((member) => member.health > 0); const friendlySummons = work.summons.filter((summon) => summon.health > 0); const onlineFriendlies = onlinePlayerRuntimeActors().filter((actor) => actor.health > 0); const candidates = [...new Set([ PLAYER_AURA_ENTITY_ID, ...partyMembers.map((member) => member.id), ...friendlySummons.map((summon) => summon.id), ...onlineFriendlies.map((actor) => actor.id), ])]; const positionFor = (id: string): CombatPosition | null => id === PLAYER_AURA_ENTITY_ID ? playerOrigin : getPartyRuntimePosition(id) ?? friendlySummons.find((summon) => summon.id === id)?.position ?? null; const center = targetId ? positionFor(targetId) ?? playerOrigin : playerOrigin; const radius = talentAdjustedRange(targeting.radius ?? ability.radius ?? 0, ability, modifiers); return candidates .filter((id) => { const position = positionFor(id); return Boolean(position && Math.sqrt(distanceSquared(center, position)) <= radius && combatHasLineOfSight(center, position)); }) .sort((left, right) => Number(right === targetId) - Number(left === targetId) || left.localeCompare(right)) .slice(0, targeting.maximumTargets ?? Infinity); })() : null; let clearedSingleTargetControls = false; let missileBarrageSlowCastChecked = false; let abilityFeedback: { kind: CombatFeedbackKind; message: string } | null = null; let romPriestHolyCandleChargedForCast = completingCast?.romPriestHolyCandleCharged ?? false; const shamanLavaLashOffhandSpeedMs = state.classId === "shaman" && ability.name === "Lava Lash" ? playerEquippedMeleeWeapon(state, "off-hand")?.speedMs : undefined; const shamanStormstrikeOffhandWeapon = state.classId === "shaman" && ability.name === "Stormstrike" ? playerEquippedMeleeWeapon(state, "off-hand") : null; let shamanTidalWavesProcAttempted = false; const landedDirectHitTargetIdsForCast = new Set(); const criticalDirectHitTargetIdsForCast = new Set(); const combatEventIdBeforeAbilityEffects = work.combatEvents.at(-1)?.id ?? 0; let suddenDoomTrigger: ReturnType = null; let romBeaconHealTriggeredForCast = false; let deathKnightReapingProcAttemptedForCast = false; let deathKnightRuneSpendRestoreAttemptedForCast = false; const isRomChampionDarkEnergyStrike = romChampionDarkEnergyStrikeMatchesBuild( state.classId, state.secondaryClassId, ability, ); let romChampionDarkEnergyStrikeDamageIndex = 0; const romChampionDarkEnergyStrikeGroup0SurvivedTargets = new Set(); const attemptDeathKnightReapingOnLandedTrigger = (triggerTargetId: string): void => { if (state.classId !== "death-knight" || deathKnightReapingProcAttemptedForCast) return; if (!deathKnightRuneSpendRestoreAttemptedForCast) { work.deathKnightRunes = wow335DeathKnightReapingRestoreLandedSpend( work.deathKnightRunes, spentDeathKnightRuneSlots, true, ); deathKnightRuneSpendRestoreAttemptedForCast = true; } // Consume the event opportunity even when its generated rank roll fails; // multi-packet abilities are a single cast for this talent check. deathKnightReapingProcAttemptedForCast = true; const reapingRank = work.talentRanks["talent-2001"] ?? 0; const shouldProc = wow335DeathKnightReapingShouldProc(reapingRank, { spellId: ability.dbcSpellId ?? -1, landed: true, roll: stableCombatUnit( `${PLAYER_AGGRO_ID}:${ability.id}:${triggerTargetId}:${now}:wow335-death-knight-reaping`, ), }); if (!shouldProc) return; const reapingState = wow335DeathKnightReapingStateForRunes(work.deathKnightRunes, now); const reapingPlan = wow335DeathKnightReapingProcPlan( reapingRank, reapingState, state.deathKnightRunes, work.deathKnightRunes, now, ); work.deathKnightRunes = wow335DeathKnightReapingApplyProcPlan( work.deathKnightRunes, reapingPlan, ); }; const utilityHit = targetId ? resolveNpcUtilityBlockResult( work, targetId, PLAYER_AGGRO_ID, playerEffectiveStats, ability, now, ) : null; if (utilityHit && targetId) { work.combatResults.push(utilityHit); recordCombatEventInWork(work, utilitySpellHitEvent(utilityHit, targetId, PLAYER_AGGRO_ID, ability, now)); } for (const effect of utilityHit?.blocked ? [] : effectsToResolve) { 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 (state.classId === "warlock" && ability.name === "Seed of Corruption" && effect.kind === "apply-aura" && effect.sourceAuraType === 4) continue; if (state.classId === "hunter" && ability.name === "Explosive Trap" && effect.kind === "dot") continue; if (ability.name === "Penance" && effectiveTarget === "friendly" && (effect.kind === "damage" || effect.kind === "dot")) continue; if (ability.name === "Divine Hymn" && effect.kind === "apply-aura") continue; if ( ability.target === "any" && effectiveTarget === "hostile" && ["heal", "hot", "shield", "resurrection"].includes(effect.kind) ) continue; if ( effect.kind === "apply-aura" && ability.target === "any" && ( (effectiveTarget === "friendly" && effect.aura.disposition === "debuff") || (effectiveTarget === "hostile" && effect.aura.disposition === "buff" && effect.recipient !== "caster") ) ) continue; if (effect.kind === "damage") { const darkEnergyStrikeGroup = isRomChampionDarkEnergyStrike ? romChampionDarkEnergyStrikePacketGroupAtDamageIndex(romChampionDarkEnergyStrikeDamageIndex++) : null; const weaponHitProfile = effect.weaponHand === "main-hand" ? playerAutoAttackProfile( state, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), playerEquippedMeleeWeapon(state, "main-hand") ?? undefined, now, ) : effect.weaponHand === "off-hand" && shamanStormstrikeOffhandWeapon ? playerOffhandAutoAttackProfile( state, shamanStormstrikeOffhandWeapon, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), 0, now, ) : null; if (effect.weaponHand && !weaponHitProfile) continue; for (const [targetIndex, id] of affectedMobIds.entries()) { const nativeTargetDamage = playerEffectiveStats.romNative ? nativeDamageCasterAmount(effect, work.progression.level, playerEffectiveStats.romNative, { targetCount: affectedMobIds.length, targetOrdinal: targetIndex }, playerDisabledWeaponChannels.has("main-hand") ? nativeDisarmedMainHandAutoAttackProfile(playerEffectiveStats.romNative) : undefined) : null; const amount = talentAdjustedAmount( (() => { const conflagrateDot = state.classId === "warlock" && ability.name === "Conflagrate" ? activeWarlockConflagrateDot(work.effects, targetId ?? "", PLAYER_AGGRO_ID, now) : null; const sourceAbility = conflagrateDot ? abilityById(conflagrateDot.sourceAbilityId) : null; const burstSeconds = sourceAbility?.name === "Immolate" ? 12 : 8; const baseDamage = weaponHitProfile ? weaponHitProfile.damage * effect.coefficient : conflagrateDot && sourceAbility ? (conflagrateDot.fixedAmount ?? equipmentAdjustedCombatAmount( conflagrateDot, work.progression.level, work.gearStats, state.classId, sourceAbility, 0, undefined, work.raceId, work.gender, work.statRuleset, work.secondaryClassId, )) * burstSeconds * 1_000 / Math.max(1, conflagrateDot.intervalMs) : equipmentAdjustedCombatAmount( effect, work.progression.level, work.gearStats, state.classId, ability, holyBlessingSpellPowerBonus, playerCurrentIntellect, work.raceId, work.gender, work.statRuleset, work.secondaryClassId, playerEffectiveStats.romNative, { targetCount: affectedMobIds.length, targetOrdinal: targetIndex }, playerDisabledWeaponChannels, ); return Math.round(baseDamage) + (effect.bonusFlat ?? 0); })(), modifiers, "damage-percent", ability, ); if (darkEnergyStrikeGroup === "dark" && !romChampionDarkEnergyStrikeGroup0SurvivedTargets.has(id)) continue; const effectSchool = effect.school ?? damageSchool; const target = work.mobs[id]; const faithAdjustedAmount = target ? priestTestOfFaithAmount(amount, state.classId, state.talentRanks, ability, "damage", target.health, target.maxHealth) : amount; const targetAmount = consumesSurgeOfLight && ability.name === "Smite" ? Math.round(faithAdjustedAmount * 1.6) : faithAdjustedAmount; const mageTargetAmount = state.classId === "mage" && target ? wow335MageTormentTheWeakDamage( targetAmount, work.talentRanks[WOW335_MAGE_TORMENT_THE_WEAK_TALENT_ID] ?? 0, ability.name, target.statuses, now, ) : targetAmount; const sunderStacks = effect.bonusPerSunderStack ? work.auras.find((aura) => aura.targetId === id && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0 : 0; const maelstromWeaponPvpDamageReductionPercent = target?.characterDefender && attackKind === "spell" ? wow335ShamanMaelstromWeaponPvpDamageReductionPercent( maelstromWeaponRankForCast, maelstromWeaponStacksConsumed, ability.name, ) : 0; const ownerDirectDamageBeforeMerciless = (mageTargetAmount + sunderStacks * (effect.bonusPerSunderStack ?? 0)) * (nativeTargetDamage !== null ? 1 : Math.pow(effect.falloffPerJump ?? 1, targetIndex)) * (1 - maelstromWeaponPvpDamageReductionPercent / 100); const mercilessCombatRank = state.classId === "death-knight" && ability.classId === "death-knight" ? work.talentRanks[WOW335_DEATH_KNIGHT_MERCILESS_COMBAT_TALENT_ID] ?? 0 : 0; const ownerDirectRawDamage = Math.round(target ? wow335DeathKnightMercilessCombatDamage( ownerDirectDamageBeforeMerciless, mercilessCombatRank, ability.name, target.health / target.maxHealth, ) : ownerDirectDamageBeforeMerciless); const directHitSeed = `${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct${effect.weaponHand ? `:${effect.weaponHand}` : ""}`; const result = resolveAndDamageMobInWork( state.classId, work, id, ownerDirectRawDamage, now, castThreatSource, effectSchool === "physical" ? attackKind : "spell", effectSchool, directHitSeed, ability, false, { ...(ability.name === "Shiv" || (consumesSurgeOfLight && ability.name === "Smite") ? { canCrit: false } : {}), romNativeSource: effect.romNativeSource, romNativeTargetOrdinal: targetIndex }, mageArcanePotencyCriticalChanceBonusPercent, effect.weaponHand === "main-hand" ? playerEquippedMeleeWeapon(state, "main-hand")?.speedMs ?? work.gearStats.meleeWeapon?.speedMs ?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS : effect.weaponHand === "off-hand" ? shamanStormstrikeOffhandWeapon?.speedMs : shamanLavaLashOffhandSpeedMs, ); if (darkEnergyStrikeGroup === "physical" && id === targetId && romChampionDarkEnergyStrikeGroup0Survives({ effectiveDamage: result.amount, combatResult: result.combatResult, })) { romChampionDarkEnergyStrikeGroup0SurvivedTargets.add(id); } if (effect.kind === "damage") { maybeResolveShamanLightningOverloadInWork( work, id, ownerDirectRawDamage, now, castThreatSource, ability, effectSchool, result.combatResult?.landed ?? result.amount > 0, Boolean(result.combatResult?.critical), false, directHitSeed, ); } if (state.classId === "death-knight" && effect.kind === "damage" && id === targetId && result.combatResult?.landed && result.amount > 0) { maybeMirrorDeathKnightDancingRuneWeaponSpellInWork( work, id, result.combatResult.rawDamage ?? ownerDirectRawDamage, now, castThreatSource, ability, effectSchool, affectedMobIds.length > 1 || Boolean(ability.radius), id === targetId, ); } if (effect.kind === "damage" && (result.combatResult?.landed ?? result.amount > 0)) { landedDirectHitTargetIdsForCast.add(id); if (result.combatResult?.critical) criticalDirectHitTargetIdsForCast.add(id); } const beheaderRefund = effect.kind === "damage" ? coaBarbarianBeheaderEnergyRefund(ability, work.talentRanks, work.maxResourcePools.energy ?? work.maxResource, result) : 0; if (beheaderRefund > 0) { const currentEnergy = work.resourcePools.energy ?? work.resource; const maximumEnergy = work.maxResourcePools.energy ?? work.maxResource; const restored = Math.max(0, Math.min(beheaderRefund, maximumEnergy - currentEnergy)); work.resourcePools.energy = currentEnergy + restored; work.resource = work.resourcePools.energy; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: castThreatSource.actorId, targetActorId: castThreatSource.actorId, abilityId: ability.id, rawAmount: beheaderRefund, effectiveAmount: restored, overheal: beheaderRefund - restored, absorbed: 0, critical: false, }); } if (effect.kind === "damage" && id === targetId && ability.source === "runewaker" && ability.dbcSpellId === ROM_COA_WARRIOR_ROGUE_PROBING_ATTACK_ID) { applyRomCoaExhaustionProcInWork( work, ability, Boolean(result.combatResult?.landed ?? result.amount > 0), stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${id}:${now}:rom-coa-exhaustion`), now, ); } if (state.classId === "death-knight" && effect.kind === "damage" && id === targetId && result.combatResult?.landed) { attemptDeathKnightReapingOnLandedTrigger(id); suddenDoomTrigger = wow335DeathKnightSuddenDoomTriggerForLandedHit( state.talentRanks["talent-1955"] ?? 0, ability.id, id, true, stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${id}:${now}:wow335-death-knight-sudden-doom`), ); } applyDruidFeralInstinctCooldownReductionInWork(work, ability, result.amount, now); if (state.classId === "rom-warrior" && state.secondaryClassId === "rom-scout" && ability.source === "runewaker" && ability.dbcSpellId === ROM_WARRIOR_SCOUT_SKULL_BREAKER_SPELL_ID && romWarriorPassiveSpellIds.includes(ROM_WARRIOR_SCOUT_CALM_PASSIVE_SPELL_ID) && result.combatResult && !result.combatResult.landed) { work.auras = [...applyCombatAura(work.auras, { definition: ROM_WARRIOR_SCOUT_CALM_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } if (state.classId === "priest" && ability.name === "Smite" && result.amount > 0 && !innerFocusSerendipityBlocked) { priestSerendipityTriggered = true; } if (state.classId === "mage" && effect.kind === "damage" && attackKind === "spell") { const masterOfElementsRefund = wow335MageMasterOfElementsManaRefund( state.talentRanks[WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID] ?? 0, manaSpentForCast, true, Boolean(result.combatResult?.critical), ); if (masterOfElementsRefund > 0 && result.combatResult?.critical) { const maximumMana = work.maxResourcePools.mana ?? defaultResourceMaximum("mana"); const currentMana = work.resourcePools.mana ?? 0; const restored = Math.max(0, Math.min(masterOfElementsRefund, maximumMana - currentMana)); work.resourcePools.mana = currentMana + restored; if (resourceProfileForClass(state.classId).type === "mana") work.resource = currentMana + restored; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: castThreatSource.actorId, targetActorId: castThreatSource.actorId, abilityId: ability.id, rawAmount: masterOfElementsRefund, effectiveAmount: restored, overheal: masterOfElementsRefund - restored, absorbed: 0, critical: false, }); } } if (state.classId === "warlock" && ability.name === "Conflagrate" && result.combatResult?.landed) { const aftermath = id === targetId ? warlockAftermathDazeForRank( warlockTalentRank(work.talentRanks, "Aftermath", 2), stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${id}:${now}:wow335-warlock-aftermath`), ) : null; if (aftermath) { const currentTarget = work.mobs[id]; if (currentTarget) { work.mobs[id] = applyStatus(currentTarget, { kind: "status", status: "slow", durationMs: aftermath.durationMs, magnitude: aftermath.movementSpeedReductionPercent / 100, }, ability.id, now, PLAYER_AGGRO_ID); } } const consumedDot = activeWarlockConflagrateDot(work.effects, id, PLAYER_AGGRO_ID, now); if (consumedDot) { const consumedName = abilityById(consumedDot.sourceAbilityId)?.name; work.effects = work.effects.filter((candidate) => candidate.id !== consumedDot.id); if (consumedName) { work.auras = work.auras.filter((aura) => !( aura.sourceId === consumedDot.sourceActorId && aura.targetId === id && aura.definition.name.startsWith(consumedName) )); } } } grantDamageResourceInWork(work, effect, result.amount, castThreatSource.actorId, ability.id, now); if (ability.dbcSpellId === 490133) { const bonus = romScoutRogueWhirlwindBonusEffect( ability, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID).flatMap((aura) => aura.definition.utilityTags ?? []), work.effects.filter((active) => active.targetId === id && active.remainingTicks > 0) .flatMap((active) => active.tag ? [active.tag] : []), ); if (bonus) { const bonusAmount = talentAdjustedAmount( equipmentAdjustedCombatAmount(bonus, work.progression.level, work.gearStats, state.classId, ability, 0, undefined, state.raceId, state.gender, state.statRuleset, state.secondaryClassId, playerEffectiveStats.romNative, undefined, playerDisabledWeaponChannels), modifiers, "damage-percent", ability, ); resolveAndDamageMobInWork( state.classId, work, id, bonusAmount, now, castThreatSource, "melee", "physical", `${castThreatSource.actorId}:${ability.id}:${id}:${now}:poison-bonus`, ability, ); } } } } else if (effect.kind === "finisher-damage") { const comboPoints = work.comboPoints; for (const id of affectedMobIds) { const envenomDoses = effect.poison ? Math.min( comboPoints, effect.poison.maximumDoses, matchingAuraStacks( work.auras, effect.poison.doseAuraTag, castThreatSource.actorId, id, now, ), ) : 0; if ( effect.poison && envenomDoses > 0 && !rogueEnvenomPreservesDeadlyPoison( work.talentRanks, `${castThreatSource.actorId}:${id}:${ability.id}:${now}:master-poisoner`, ) ) { consumeRogueDeadlyPoisonInWork( work, castThreatSource.actorId, id, envenomDoses, now, ); } const scaling = effect.poison ? { coefficient: 0, basePoints: effect.poison.basePointsPerDose * envenomDoses, dieSides: 1, attackPowerCoefficient: effect.poison.attackPowerCoefficientPerDose * envenomDoses, } : effect.baseCoefficient + effect.perComboCoefficient * comboPoints; const amount = talentAdjustedAmount( equipmentAdjustedCombatAmount( scaling, work.progression.level, work.gearStats, state.classId, ability, 0, undefined, state.raceId, state.gender, state.statRuleset, state.secondaryClassId, playerEffectiveStats.romNative, undefined, playerDisabledWeaponChannels, ), modifiers, "damage-percent", ability, ); resolveAndDamageMobInWork( state.classId, work, id, amount, now, castThreatSource, attackKind, damageSchool, `${castThreatSource.actorId}:${ability.id}:${id}:${now}:finisher`, ability, ); } work.comboPoints = 0; work.comboTargetId = null; } else if (effect.kind === "heal") { const partyMembers = usePartyStore.getState().members; const friendlySummons = work.summons.filter((summon) => summon.health > 0); const onlineFriendlies = onlinePlayerRuntimeActors().filter((actor) => actor.health > 0); const selectedFriendly = effectiveTarget === "friendly" && targetId ? partyMembers.find((member) => member.id === targetId) : null; const selectedFriendlySummon = effectiveTarget === "friendly" && targetId ? friendlySummons.find((summon) => summon.id === targetId) : null; const selectedOnlineFriendly = effectiveTarget === "friendly" && targetId ? onlineFriendlies.find((actor) => actor.id === targetId) : null; const allLivingFriendlies = [ PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => member.health > 0).map((member) => member.id), ...friendlySummons.map((summon) => summon.id), ...onlineFriendlies.map((actor) => actor.id), ]; const friendlyPosition = (id: string): CombatPosition | null => id === PLAYER_AURA_ENTITY_ID ? playerOrigin : getPartyRuntimePosition(id) ?? friendlySummons.find((summon) => summon.id === id)?.position ?? null; const healingCenter = groundPosition ?? (targetId ? friendlyPosition(targetId) : null) ?? playerOrigin; let healingTargetIds: string[]; if (curingShotRecipients) { healingTargetIds = [...curingShotRecipients]; } else if (targeting?.shape === "chain") { const ordered = [...allLivingFriendlies].sort((left, right) => { const selectedId = selectedFriendly?.id ?? selectedFriendlySummon?.id ?? PLAYER_AURA_ENTITY_ID; if (left === selectedId) return -1; if (right === selectedId) return 1; const leftMember = partyMembers.find((member) => member.id === left); const rightMember = partyMembers.find((member) => member.id === right); const leftSummon = friendlySummons.find((summon) => summon.id === left); const rightSummon = friendlySummons.find((summon) => summon.id === right); const leftOnline = onlineFriendlies.find((actor) => actor.id === left); const rightOnline = onlineFriendlies.find((actor) => actor.id === right); const leftRatio = leftMember ? leftMember.health / leftMember.maxHealth : leftSummon ? leftSummon.health / leftSummon.maxHealth : leftOnline ? leftOnline.health / leftOnline.maxHealth : work.health / work.maxHealth; const rightRatio = rightMember ? rightMember.health / rightMember.maxHealth : rightSummon ? rightSummon.health / rightSummon.maxHealth : rightOnline ? rightOnline.health / rightOnline.maxHealth : work.health / work.maxHealth; return leftRatio - rightRatio || left.localeCompare(right); }); healingTargetIds = []; for (const candidate of ordered) { if (healingTargetIds.length >= (targeting.maximumTargets ?? 3)) break; const position = friendlyPosition(candidate); const previousPosition = healingTargetIds.length ? friendlyPosition(healingTargetIds.at(-1)!) : playerOrigin; if (!position || !previousPosition) continue; if (Math.sqrt(distanceSquared(previousPosition, position)) > (targeting.jumpRange ?? 12)) continue; if (!combatHasLineOfSight(previousPosition, position)) continue; healingTargetIds.push(candidate); } } else if ((targeting?.shape === "circle" || targeting?.shape === "ground") && effectiveTarget !== "hostile") { const radius = talentAdjustedRange(targeting.radius ?? ability.radius ?? 0, ability, modifiers); healingTargetIds = allLivingFriendlies.filter((id) => { const position = friendlyPosition(id); return Boolean(position && Math.sqrt(distanceSquared(healingCenter, position)) <= radius && combatHasLineOfSight(healingCenter, position)); }); } else { healingTargetIds = [selectedFriendly?.id ?? selectedFriendlySummon?.id ?? selectedOnlineFriendly?.id ?? PLAYER_AURA_ENTITY_ID]; } const casterStats = applyAuraStats( playerCharacterStats(state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, modifiers, state.gender, state.statRuleset), aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), ); const criticalChanceBasePercent = ( casterStats.spellCritPercent + applyTalentModifierValue( 0, modifiers, "critical-chance-percent", abilityTalentContext(ability), ) + shamanElementalOathCriticalChancePercent(state.classId, work.talentRanks, "spell") + (state.classId === "shaman" ? wow335ShamanTidalWavesCriticalChanceBonusPercent(ability.name, tidalWavesChargesForCast) : 0) + (consumesShamanNaturesGuardianCrit ? 100 : 0) + (divineFavorReady ? 100 : 0) ); const receivesTidalForceBonus = state.classId === "shaman" && ["Healing Wave", "Lesser Healing Wave", "Chain Heal"].includes(ability.name); let directHealing = 0; const splashGroundCenterId = isCoaWitchDoctorSplashPotion(ability) && groundPosition ? "coa:splash-potion-ground-center" : null; healingTargetIds = [...healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, splashGroundCenterId ?? targetId, work.actors, id => id === splashGroundCenterId ? groundPosition : friendlyPosition(id) ?? getMobPosition(id), healingTargetIds, combatHasLineOfSight, now)]; if (targeting?.maximumTargets) healingTargetIds = healingTargetIds.slice(0, targeting.maximumTargets); if (!romPriestHolyCandleChargedForCast && healingTargetIds.length > 0 && applyRomPriestHolyCandleInWork(work, PLAYER_AURA_ENTITY_ID, ability.id, now)) { romPriestHolyCandleChargedForCast = true; } for (const [targetIndex, healingTargetId] of [...new Set(healingTargetIds)].entries()) { const shamanRiptideHot = state.classId === "shaman" && ability.name === "Chain Heal" && targetIndex === 0 ? shamanRiptideHotOnTargetInWork(work, healingTargetId, now) : undefined; if (shamanRiptideHot) { work.effects = work.effects.filter((candidate) => candidate.id !== shamanRiptideHot.id); } const friendlyTarget = partyMembers.find((member) => member.id === healingTargetId); const friendlySummon = work.summons.find((summon) => summon.id === healingTargetId); const friendlyOnline = onlineFriendlies.find((actor) => actor.id === healingTargetId); const healingTargetMaxHealth = friendlyTarget?.maxHealth ?? friendlySummon?.maxHealth ?? friendlyOnline?.maxHealth ?? work.maxHealth; const healingTargetHealth = friendlyTarget?.health ?? friendlySummon?.health ?? friendlyOnline?.health ?? work.health; const swiftmendCandidates = state.classId === "druid" && ability.name === "Swiftmend" ? work.effects.filter((candidate) => { if (candidate.kind !== "hot" || (candidate.targetId ?? PLAYER_AURA_ENTITY_ID) !== healingTargetId || candidate.remainingTicks <= 0) return false; const sourceName = abilityById(candidate.sourceAbilityId)?.name; return sourceName === "Rejuvenation" || sourceName === "Regrowth"; }) : []; const casterSwiftmendCandidates = swiftmendCandidates.filter( (candidate) => candidate.sourceActorId === PLAYER_AGGRO_ID, ); const swiftmendPool = casterSwiftmendCandidates.length > 0 ? casterSwiftmendCandidates : swiftmendCandidates; const swiftmendHot = swiftmendPool.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); const swiftmendHotAbility = swiftmendHot ? abilityById(swiftmendHot.sourceAbilityId) : null; const swiftmendTickCount = swiftmendHotAbility?.name === "Rejuvenation" ? 4 : swiftmendHotAbility?.name === "Regrowth" ? 6 : 0; const swiftmendAmount = swiftmendHot && swiftmendHotAbility ? talentAdjustedAmount( swiftmendHot.fixedAmount ?? equipmentAdjustedCombatAmount( swiftmendHot, work.progression.level, work.gearStats, state.classId, swiftmendHotAbility, 0, undefined, state.raceId, state.gender, state.statRuleset, state.secondaryClassId, ), [...modifiers, ...druidMasterShapeshifterHealingModifiers], "healing-percent", swiftmendHotAbility, true, ) * swiftmendTickCount : null; const deathStrikeDiseaseCount = ability.name === "Death Strike" && state.classId === "death-knight" ? new Set(work.effects.filter((candidate) => candidate.kind === "dot" && candidate.targetId === targetId && candidate.sourceActorId === PLAYER_AGGRO_ID && candidate.remainingTicks > 0 && (candidate.tag === "blood-plague" || candidate.tag === "frost-fever")) .map((candidate) => candidate.kind === "dot" ? candidate.tag : undefined)).size : 1; const runeTapBonus = state.classId === "death-knight" && ability.name === "Rune Tap" ? [0, 0.33, 0.66, 1][improvedRuneTapRank] ?? 0 : 0; const tidalWavesBonusHealingAmount = state.classId === "shaman" && ["Healing Wave", "Lesser Healing Wave"].includes(ability.name) ? Math.round(Math.max(0, casterStats.spellPower) * wow335ShamanTidalWavesBonusHealingPercent(shamanTidalWavesRank)) : 0; const sourceHealAmount = ((effect.percentOfDamage !== undefined ? Math.round(work.totalDamage * effect.percentOfDamage) : equipmentAdjustedCombatAmount( effect, work.progression.level, work.gearStats, state.classId, ability, holyBlessingSpellPowerBonus, undefined, state.raceId, state.gender, state.statRuleset, state.secondaryClassId, casterStats.romNative, ) + Math.round(healingTargetMaxHealth * (effect.percentMaxHealth ?? 0))) * deathStrikeDiseaseCount) * (1 + runeTapBonus) + tidalWavesBonusHealingAmount; const unmodifiedAmount = talentAdjustedAmount( swiftmendAmount ?? sourceHealAmount, [...modifiers, ...druidMasterShapeshifterHealingModifiers], "healing-percent", ability, ); const targetAdjustedAmount = priestTestOfFaithAmount( unmodifiedAmount, state.classId, state.talentRanks, ability, "healing", healingTargetHealth, healingTargetMaxHealth, ); if (swiftmendHot && !state.glyphIds.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)) { work.effects = work.effects.filter((candidate) => candidate.id !== swiftmendHot.id); } const riptideAdjustedAmount = shamanRiptideHot ? Math.round(targetAdjustedAmount * SHAMAN_RIPTIDE_CHAIN_HEAL_BONUS) : targetAdjustedAmount; const falloffAdjustedAmount = Math.round(riptideAdjustedAmount * Math.pow(effect.falloffPerJump ?? 1, targetIndex)); const amount = Math.round(resolveAuraHealingValue( resolveAuraHealingValue( state.statRuleset === "rom" ? nativeHolyHealTargetAmount( falloffAdjustedAmount, effect, nativeHealingTargetStats(healingTargetId, work, work.progression.level, work.auras, now), ) : falloffAdjustedAmount, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "done", ), aurasForEntity(work.auras, healingTargetId), "taken", )); const tidalForceAura = state.classId === "shaman" ? work.auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-tidal-force") && (aura.expiresAt === null || aura.expiresAt > now)) : undefined; const tidalForceBonusPercent = receivesTidalForceBonus ? (tidalForceAura?.stacks ?? 0) * 20 : 0; const criticalChance = Math.max(0, (criticalChanceBasePercent + tidalForceBonusPercent) / 100); const resolution = resolveHealing( amount, healingTargetHealth, healingTargetMaxHealth, criticalChance, stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal`), ); if (state.classId === "paladin" && resolution.critical) { const sourceAbility = abilityById(ability.id) ?? ability; const spellBaseManaCost = completingCast?.illuminationBaseManaCost ?? abilityAtLevel(sourceAbility, work.progression.level).cost.amount; const illumination = wow335PaladinIlluminationRefundPlan( work.talentRanks[WOW335_PALADIN_ILLUMINATION_TALENT_ID] ?? 0, { casterClassId: state.classId, abilityId: ability.id, abilityName: ability.name, criticalHeal: true, spellBaseManaCost, roll: stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:wow335-illumination`), }, ); if (illumination) { const maximum = work.maxResourcePools.mana ?? defaultResourceMaximum("mana"); const current = work.resourcePools.mana ?? 0; const restored = Math.min(illumination.manaRefund, Math.max(0, maximum - current)); if (restored > 0) { work.resourcePools.mana = current + restored; if (resourceProfileForClass(work.classId).type === "mana") work.resource = current + restored; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AGGRO_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, rawAmount: illumination.manaRefund, effectiveAmount: restored, overheal: illumination.manaRefund - restored, absorbed: 0, critical: false, }); } } } if (resolution.critical && tidalForceAura) { work.auras = work.auras.flatMap((aura) => { if (aura.instanceId !== tidalForceAura.instanceId) return [aura]; return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; }); } const healed = friendlyTarget ? healCompanionActorInWork(work, healingTargetId, resolution.rawAmount) : friendlySummon ? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health) : friendlyOnline ? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health) : healPlayerInWork(work, resolution.rawAmount); if (friendlySummon && healed > 0) { work.summons = work.summons.map((summon) => summon.id === healingTargetId ? { ...summon, health: summon.health + healed } : summon); const actor = work.actors[healingTargetId]; if (actor) work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true }; } else if (friendlyOnline && healed > 0) { const actor = work.actors[healingTargetId] ?? friendlyOnline; work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true }; } recordCombatEventInWork(work, { occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID, targetActorId: healingTargetId, abilityId: ability.id, school: spellSchool, rawAmount: resolution.rawAmount, effectiveAmount: healed, overheal: Math.max(0, resolution.rawAmount - healed), absorbed: 0, critical: resolution.critical, }); if (state.classId === "shaman") { maybeProcShamanHealingTalentsInWork( work, ability, healingTargetId, playerOrigin, resolution.rawAmount, spellSchool, resolution.critical, now, ); if (!shamanTidalWavesProcAttempted && ["Chain Heal", "Riptide"].includes(ability.name)) { shamanTidalWavesProcAttempted = true; maybeProcShamanTidalWavesInWork(work, ability, resolution.rawAmount, now); } } if (state.classId === "priest") { const divineAegisRank = priestTalentRank(work.talentRanks, "Divine Aegis", 3); if (divineAegisRank > 0 && resolution.critical && spellSchool === "holy") { const absorbPercent = [0, 0.04, 0.07, 0.1][divineAegisRank] ?? 0; const singleTargetMultiplier = healingTargetIds.length === 1 ? 2 : 1; const requestedAbsorb = Math.round((healed + Math.max(0, resolution.rawAmount - healed) * 0.25) * absorbPercent * singleTargetMultiplier); const absorbCap = Math.round(healingTargetMaxHealth * 0.1); const previousAegis = work.auras.find((aura) => aura.targetId === healingTargetId && aura.sourceId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-priest-divine-aegis") && (aura.expiresAt === null || aura.expiresAt > now)); const previousAbsorb = previousAegis?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0) ?? 0; const nextAbsorb = Math.min(absorbCap, previousAbsorb + requestedAbsorb); const grantedAbsorb = Math.max(0, nextAbsorb - previousAbsorb); if (nextAbsorb > 0) { work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:priest:divine-aegis", name: "Divine Aegis", description: "Absorbs damage for 6 seconds, up to 10% of the target's maximum health.", disposition: "buff", durationMs: 6_000, stackBehavior: "replace", utilityTags: ["wow335-priest-divine-aegis"], absorbs: [{ id: "wow335:priest:divine-aegis:absorb", amount: nextAbsorb }], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: healingTargetId, now, }).auras]; } if (grantedAbsorb > 0) recordCombatEventInWork(work, { occurredAt: now, kind: "absorb", sourceActorId: PLAYER_AGGRO_ID, targetActorId: healingTargetId, abilityId: ability.id, school: "holy", rawAmount: grantedAbsorb, effectiveAmount: grantedAbsorb, overheal: 0, absorbed: grantedAbsorb, critical: false, }); } if (resolution.critical) { const surgeRank = priestTalentRank(work.talentRanks, "Surge of Light", 2); const surgeChance = [0, 0.25, 0.5][surgeRank] ?? 0; if (surgeRank > 0 && stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal:wow335-priest-surge-of-light`) < surgeChance) { applyPriestSelfAuraInWork(work, PRIEST_SURGE_OF_LIGHT_AURA, now); } } if (resolution.critical && ["Flash Heal", "Greater Heal", "Binding Heal"].includes(ability.name)) { const holyConcentrationRank = priestTalentRank(work.talentRanks, "Holy Concentration", 3); if (holyConcentrationRank > 0) { applyPriestSelfAuraInWork(work, { ...PRIEST_HOLY_CONCENTRATION_BASE, modifiers: [{ kind: "stat", stat: "spirit", operation: "percent", value: ([0, 0.1, 0.2, 0.3][holyConcentrationRank] ?? 0), }], }, now); } } } if (state.classId === "druid" && resolution.critical) { maybeProcDruidLivingSeed( work, ability, healingTargetId, true, healed, `${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal:wow335-druid-living-seed`, now, ); maybeProcDruidNatureGrace( work, ability, true, `${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal:druid-nature-grace`, now, ); } // Omen of Clarity observes the healing spell itself, including // healing that becomes overheal. Run it before the effective-heal // guard so a full-health target does not suppress a valid proc. if (state.classId === "druid" && resolution.rawAmount > 0) { maybeProcDruidOmenOfClarity( work, `${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal:druid-omen-of-clarity`, now, ); } directHealing += healed; if (state.classId === "priest" && healed > 0 && !innerFocusSerendipityBlocked && ["Binding Heal", "Flash Heal", "Holy Light", "Flash of Light"].includes(ability.name)) { priestSerendipityTriggered = true; } if (healed <= 0) continue; evaluateProcsInWork(work, { trigger: "heal", subjectId: PLAYER_AURA_ENTITY_ID, otherId: healingTargetId, abilityId: ability.id, amount: healed, }, now); } if (!romBeaconHealTriggeredForCast && ability.castMode !== "channel" && state.classId === "rom-priest" && state.secondaryClassId === "rom-champion") { romBeaconHealTriggeredForCast = true; const beaconHeal = resolveRomPriestChampionBeaconFromAuras( work.auras, PLAYER_AURA_ENTITY_ID, now, "direct-cast", ); if (beaconHeal && (beaconHeal.targetId === PLAYER_AURA_ENTITY_ID || partyMembers.some((member) => member.id === beaconHeal.targetId))) { const modifiedBeaconAmount = Math.round(resolveAuraHealingValue( resolveAuraHealingValue( beaconHeal.amount, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID, now), "done", ), aurasForEntity(work.auras, beaconHeal.targetId, now), "taken", )); const healed = beaconHeal.targetId === PLAYER_AURA_ENTITY_ID ? healPlayerInWork(work, modifiedBeaconAmount) : healCompanionActorInWork(work, beaconHeal.targetId, modifiedBeaconAmount); directHealing += healed; recordCombatEventInWork(work, { occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID, targetActorId: beaconHeal.targetId, abilityId: ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_ID, school: "holy", rawAmount: modifiedBeaconAmount, effectiveAmount: healed, overheal: Math.max(0, modifiedBeaconAmount - healed), absorbed: 0, critical: false, }); } } work.totalHealing += directHealing; const beacon = work.auras.find((aura) => ( aura.sourceId === PLAYER_AURA_ENTITY_ID && !healingTargetIds.includes(aura.targetId) && aura.definition.utilityTags?.includes("beacon-of-light") )); if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) { work.totalHealing += healPlayerInWork(work, directHealing); } else if (beacon) { work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing); } addHealingThreatInWork(work, castThreatSource, directHealing, now); } else if (effect.kind === "resurrection") { const paladinRankHealth = state.classId === "paladin" && ability.name === "Redemption" ? effect.flatHealth : undefined; const revivalActor = targetId ? ensureCompanionActorInWork(work, targetId) : null; const reviveFraction = paladinRankHealth !== undefined && revivalActor ? Math.min(1, paladinRankHealth / Math.max(1, revivalActor.maxHealth)) : effect.percentMaxHealth; const restored = targetId ? reviveCompanionActorInWork(work, targetId, reviveFraction) : 0; work.totalHealing += restored; if (restored > 0 && targetId) recordCombatEventInWork(work, { occurredAt: now, kind: "resurrection", sourceActorId: PLAYER_AGGRO_ID, targetActorId: targetId, abilityId: ability.id, rawAmount: restored, effectiveAmount: restored, overheal: 0, absorbed: 0, critical: false, }); addHealingThreatInWork(work, castThreatSource, restored, now); } else if (effect.kind === "summon") { summonSequence += 1; if (/totem/i.test(ability.name)) { work.summons = work.summons.filter((summon) => summon.exclusiveGroup !== "totem"); } const summonPosition = groundPosition ?? playerOrigin; work.summons.push({ id: `player-summon-${summonSequence}`, sourceAbilityId: ability.id, creatureId: effect.creatureId, targetId, expiresAt: now + effect.durationMs, kind: /totem/i.test(ability.name) ? "totem" : "summon", name: ability.name, ownerActorId: PLAYER_AGGRO_ID, health: effect.healingPulse ? 5 : Math.max(1, Math.round(work.maxHealth * 0.3)), maxHealth: effect.healingPulse ? 5 : Math.max(1, Math.round(work.maxHealth * 0.3)), position: summonPosition, nextActionAt: now + (effect.antiMagicZone?.intervalMs ?? effect.healingPulse?.intervalMs ?? 1_000), pulseIntervalMs: effect.healingPulse?.intervalMs ?? (ability.name === "Shadowfiend" ? 1_500 : /totem/i.test(ability.name) ? 2_000 : 2_200), attackDamage: effect.healingPulse || ability.name === "Lightwell" ? 0 : Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))), ...(ability.name === "Lightwell" ? { clickCharges: 10 } : {}), ...(effect.healingPulse ? { healingPulse: effect.healingPulse } : {}), ...(effect.antiMagicZone ? { antiMagicZone: effect.antiMagicZone, attackDamage: 0 } : {}), ...initialSummonCombatRuntime(), ...(/totem/i.test(ability.name) ? { exclusiveGroup: "totem" } : {}), }); if (effect.antiMagicZone) { applyDeathKnightAntiMagicZoneInWork( work, summonPosition, effect.antiMagicZone.radius, effect.durationMs, effect.antiMagicZone.capacityBase, effect.antiMagicZone.capacityPerAttackPower, effect.antiMagicZone.absorbFraction, now, playerOrigin, ); } if (ability.target === "hostile") { const amount = talentAdjustedAmount( equipmentAdjustedCombatAmount( effect, work.progression.level, work.gearStats, state.classId, ability, 0, undefined, state.raceId, state.gender, state.statRuleset, state.secondaryClassId, ), modifiers, "damage-percent", ability, ); for (const id of affectedMobIds) { resolveAndDamageMobInWork( state.classId, work, id, amount, now, castThreatSource, attackKind, damageSchool, `${castThreatSource.actorId}:${ability.id}:${id}:${now}:summon`, ability, ); } } } else if (effect.kind === "shield") { const amount = talentAdjustedAmount( equipmentAdjustedCombatAmount( effect, work.progression.level, work.gearStats, state.classId, ability, holyBlessingSpellPowerBonus, 0, state.raceId, state.gender, state.statRuleset, state.secondaryClassId, ), [...modifiers, ...druidMasterShapeshifterHealingModifiers], "healing-percent", ability, ); const durationMs = talentAdjustedDuration(effect.durationMs, ability, modifiers); const partyMembers = usePartyStore.getState().members; const recipients = ability.radius !== undefined ? [PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => { const position = getPartyRuntimePosition(member.id); return member.health > 0 && Boolean(position && Math.sqrt(distanceSquared(playerOrigin, position)) <= ability.radius! && combatHasLineOfSight(playerOrigin, position)); }).map((member) => member.id)] : [targetId ?? PLAYER_AURA_ENTITY_ID]; const splashGroundCenterId = isCoaWitchDoctorSplashPotion(ability) && groundPosition ? "coa:splash-potion-ground-center" : null; const scopedRecipients = isCoaWitchDoctorSplashPotion(ability) ? healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, splashGroundCenterId ?? targetId, work.actors, id => id === splashGroundCenterId ? groundPosition : id === PLAYER_AURA_ENTITY_ID ? playerOrigin : getPartyRuntimePosition(id) ?? work.summons.find(summon => summon.id === id)?.position ?? null, recipients, combatHasLineOfSight, now) : recipients; const prioritizedRecipients = targetId && scopedRecipients.includes(targetId) ? [targetId, ...scopedRecipients.filter((id) => id !== targetId)] : scopedRecipients; const limitedRecipients = targeting?.maximumTargets ? prioritizedRecipients.slice(0, targeting.maximumTargets) : scopedRecipients; for (const recipientId of limitedRecipients) { if (recipientId === PLAYER_AURA_ENTITY_ID) { work.shield = Math.max(work.shield, amount); work.shieldExpiresAt = now + durationMs; } else { work.auras = [...applyCombatAura(work.auras, { definition: { id: `shield:${ability.id}`, name: ability.name, description: ability.description, icon: ability.icon, disposition: "buff", durationMs, stackBehavior: "replace", absorbs: [{ id: `${ability.id}:absorb`, amount }], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: recipientId, now, }).auras]; } recordCombatEventInWork(work, { occurredAt: now, kind: "absorb", sourceActorId: PLAYER_AGGRO_ID, targetActorId: recipientId, abilityId: ability.id, school: spellSchool, rawAmount: amount, effectiveAmount: amount, overheal: 0, absorbed: amount, critical: false, }); } } else if (effect.kind === "resource") { const resource = effect.resource ?? activeResourceType; if (effect.scope === "party") { const radius = ability.radius ?? 0; const recipients = [ ...(work.health > 0 ? [PLAYER_AURA_ENTITY_ID] : []), ...usePartyStore.getState().members.filter((member) => { const actor = work.actors[member.id]; const position = getPartyRuntimePosition(member.id); return actor?.alive && position !== null && distanceSquared(playerOrigin, position) <= radius * radius && combatHasLineOfSight(playerOrigin, position); }).map((member) => member.id), ]; for (const recipientId of recipients) { const playerRecipient = recipientId === PLAYER_AURA_ENTITY_ID; const actor = work.actors[recipientId]; const pool = playerRecipient ? undefined : actor?.resources[resource]; const maximum = playerRecipient ? work.maxResourcePools[resource] : pool?.maximum; const current = playerRecipient ? work.resourcePools[resource] : pool?.current; // A class can only receive resource types it actually has. Do not // synthesize a default pool for this multi-resource CoA effect. if (maximum === undefined || current === undefined) continue; const requested = effect.percentage ? maximum * effect.amount / 100 : effect.amount; const restored = Math.max(0, Math.min(requested, maximum - current)); if (restored <= 0) continue; const nextCurrent = current + restored; if (playerRecipient) { work.resourcePools[resource] = nextCurrent; if (resource === activeResourceType) work.resource = nextCurrent; if (actor?.resources[resource]) { work.actors[recipientId] = { ...actor, resources: { ...actor.resources, [resource]: { ...actor.resources[resource], current: nextCurrent }, }, }; } } else if (actor && pool) { const nextActor = { ...actor, resources: { ...actor.resources, [resource]: { ...pool, current: nextCurrent } }, }; work.actors[recipientId] = nextActor; mirrorCompanionActor(nextActor); } recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AGGRO_ID, targetActorId: recipientId, abilityId: ability.id, rawAmount: requested, effectiveAmount: restored, overheal: Math.max(0, requested - restored), absorbed: 0, critical: false, }); } continue; } const friendlyResourceTarget = effect.scope !== "caster" && effectiveTarget === "friendly" && targetId ? usePartyStore.getState().members.find((member) => member.id === targetId) : null; if (friendlyResourceTarget) { const maximum = friendlyResourceTarget.resourceType === resource ? friendlyResourceTarget.maxResource : 100; const current = friendlyResourceTarget.resourcePools[resource] ?? 0; const requested = effect.percentage ? maximum * effect.amount / 100 : effect.amount; const restored = Math.max(0, Math.min(requested, maximum - current)); const actor = ensureCompanionActorInWork(work, friendlyResourceTarget.id); if (actor) { const pool = actor.resources[resource] ?? normalizeActorResourcePool(resource, current, maximum); const next = Object.freeze({ ...actor, resources: Object.freeze({ ...actor.resources, [resource]: Object.freeze({ ...pool, current: current + restored }), }), }); work.actors[friendlyResourceTarget.id] = next; mirrorCompanionActor(next); } recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AGGRO_ID, targetActorId: friendlyResourceTarget.id, abilityId: ability.id, rawAmount: requested, effectiveAmount: restored, overheal: Math.max(0, requested - restored), absorbed: 0, critical: false, }); continue; } const targetAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID); const maximum = work.maxResourcePools[resource] ?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource)); const rawAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount; const amount = resolveAuraResourceValue( resolveAuraResourceValue(rawAmount, targetAuras, resource, "generation"), targetAuras, "power", "generation", ); const resourceBefore = work.resourcePools[resource] ?? 0; work.resourcePools[resource] = clamp(resourceBefore + amount, 0, maximum); if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource; recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AGGRO_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, rawAmount: amount, effectiveAmount: (work.resourcePools[resource] ?? 0) - resourceBefore, overheal: Math.max(0, amount - ((work.resourcePools[resource] ?? 0) - resourceBefore)), absorbed: 0, critical: false, }); } else if (effect.kind === "combo") { const comboTargetId = targetId && work.mobs[targetId] ? targetId : null; if (comboTargetId && state.classId === "rom-knight" && state.secondaryClassId === "rom-rogue") { const pairAbilities = abilitiesForCharacter(state.classId, state.secondaryClassId); const vengeanceBase = pairAbilities.find((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-knight" && candidate.secondaryClassId === "rom-rogue" && candidate.dbcSpellId === 499_934 && candidate.passive); const shadowStepBase = pairAbilities.find((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-rogue" && candidate.dbcSpellId === 490_344); const vengeance = vengeanceBase ? learnedAbilityForBuild(vengeanceBase, work.progression.level, work.talentRanks) : null; const shadowStep = shadowStepBase ? learnedAbilityForBuild(shadowStepBase, work.progression.level, work.talentRanks) : null; if (shadowStep && romKnightGodsVengeanceShouldResetShadowStep({ characterClassId: state.classId, secondaryClassId: state.secondaryClassId, ability, learnedPassiveSpellIds: vengeance ? [vengeance.dbcSpellId ?? -1] : [], shadowStepLearned: true, targetSealStacks: work.comboTargetId === comboTargetId ? work.comboPoints : 0, sameTarget: work.comboTargetId === comboTargetId, landed: landedDirectHitTargetIdsForCast.has(comboTargetId), })) { work.cooldowns[shadowStep.id] = 0; } } if ([490151, 499909, 490194].includes(ability.dbcSpellId) && comboTargetId) { work.effects = work.effects.filter((entry) => !(entry.kind === "dot" && entry.tag === "rom-holy-seal" && entry.sourceActorId === PLAYER_AGGRO_ID && entry.targetId === comboTargetId)); } work.comboPoints = clamp((work.comboTargetId === comboTargetId ? work.comboPoints : 0) + effect.amount, 0, state.classId === "rom-knight" ? 4 : 5); work.comboTargetId = comboTargetId; if (comboTargetId) { maybeProcDruidPrimalFuryCombo( work, ability, comboTargetId, effect.amount, combatEventIdBeforeAbilityEffects, now, ); } } else if (effect.kind === "dot") { if (targeting?.shape === "ground") continue; const selfDrainPercent = romSelfDrainPercent(ability.dbcSpellId); if (selfDrainPercent > 0) { work.effects = work.effects.filter((entry) => !(entry.kind === "dot" && entry.sourceAbilityId === ability.id && entry.targetId === PLAYER_AURA_ENTITY_ID)); work.effects.push({ ...timedEffect("dot", ability.id, PLAYER_AGGRO_ID, PLAYER_AURA_ENTITY_ID, effect, effect.ticks, effect.intervalMs, now, "rom-self-health-drain"), fixedAmount: Math.max(1, Math.round(work.maxHealth * selfDrainPercent / 100)), }); continue; } const seedProfile = state.classId === "warlock" ? warlockSeedProfileForAbility(ability) : null; for (const id of affectedMobIds) { const mob = work.mobs[id]; if (!mob || mob.schoolImmunities.includes(effect.school ?? damageSchool)) continue; const nativeDot = nativeOutgoingPeriodicSnapshot(effect, playerEffectiveStats.romNative, mob, work.auras, `${castThreatSource.actorId}:${ability.id}:${id}:${now}:dot-application`, castThreatSource.actorId, now); if (nativeDot === null) continue; work.effects = work.effects.filter((entry) => !( entry.kind === "dot" && entry.targetId === id && (effect.tag ? entry.tag === effect.tag : entry.sourceAbilityId === ability.id) )); const timed = timedEffect( "dot", ability.id, castThreatSource.actorId, id, nativeDot ? { ...effect, fixedAmount: nativeDot.tickDamageAtApplication } : effect, ability.name === "Rupture" ? Math.max(4, Math.min(8, 3 + work.comboPoints)) : nativeDot?.ticks ?? effect.ticks, nativeDot ? nativeDot.tickIntervalSeconds * 1_000 : effect.intervalMs, now, effect.tag, effect.school ?? damageSchool, work.gearStats.spellPenetration, mageArcanePotencyCriticalChanceBonusPercent, ); work.effects.push(nativeDot ? { ...timed, targetMitigationSnapshotted: true, nativePeriodicDamageSnapshot: nativeDot } : seedProfile ? { ...timed, tag: "wow335-warlock-seed", warlockSeed: seedProfile } : timed); } } else if (effect.kind === "hot") { if (targeting?.shape === "ground") continue; const hotTargetId = effectiveTarget === "friendly" ? targetId : null; const recipients = healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors, id => id === PLAYER_AURA_ENTITY_ID ? playerOrigin : getPartyRuntimePosition(id) ?? getMobPosition(id), curingShotRecipients ?? areaFriendlyRecipients ?? [hotTargetId ?? PLAYER_AURA_ENTITY_ID], combatHasLineOfSight, now); if (!romPriestHolyCandleChargedForCast && recipients.length > 0 && applyRomPriestHolyCandleInWork(work, PLAYER_AURA_ENTITY_ID, ability.id, now)) { romPriestHolyCandleChargedForCast = true; } for (const recipient of recipients) { const normalizedTarget = recipient === PLAYER_AURA_ENTITY_ID ? null : recipient; work.effects = work.effects.filter((entry) => !( entry.kind === "hot" && entry.sourceAbilityId === ability.id && entry.targetId === normalizedTarget )); const nativeSnapshot = nativePeriodicHealSnapshot(effect, work.progression.level, playerEffectiveStats.romNative, nativeHealingTargetStats(recipient, work, work.progression.level, work.auras, now), work.actors[recipient]?.level ?? work.progression.level); work.effects.push(timedEffect( "hot", ability.id, castThreatSource.actorId, normalizedTarget, nativeSnapshot ? { ...effect, fixedAmount: nativeSnapshot.tickAmount } : effect, nativeSnapshot?.dotTimes ?? effect.ticks, effect.intervalMs, now, )); } if (state.classId === "priest" && ability.name === "Renew" && recipients.length > 0 && !innerFocusSerendipityBlocked) { priestSerendipityTriggered = true; } } else if (effect.kind === "taunt") { const friendlyAttackers = effectiveTarget === "friendly" && targetId ? Object.values(work.mobs) .filter((mob) => !mob.dead && mob.targetActorId === targetId) .map((mob) => mob.id) : []; const tauntTargets = affectedMobIds.length ? affectedMobIds : friendlyAttackers; const candidates = effect.maxTargets ? tauntTargets.slice(0, effect.maxTargets) : tauntTargets; const durationMs = talentAdjustedDuration(effect.durationMs, ability, modifiers); for (const id of candidates) { const mob = work.mobs[id]; if (!mob || mob.dead) continue; const previous = mob.tauntDiminishingReturns; const applications = mob.boss && previous && previous.resetAt > now ? previous.applications : 0; const multiplier = mob.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1; const effectiveDurationMs = Math.round(durationMs * multiplier); const tauntDiminishingReturns = mob.boss ? { applications: Math.min(3, applications + 1), resetAt: now + 15_000 } : mob.tauntDiminishingReturns; if (effectiveDurationMs <= 0) { work.mobs[id] = { ...mob, tauntDiminishingReturns }; continue; } work.mobs[id] = { ...mob, tauntDiminishingReturns, threatByActor: tauntThreat(mob.threatByActor, castThreatSource.actorId), targetActorId: castThreatSource.actorId, forcedTarget: { actorId: castThreatSource.actorId, endsAt: now + effectiveDurationMs }, }; } } else if (effect.kind === "status") { const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds; if (romSingleTargetControl(ability) && candidates.length && !clearedSingleTargetControls) { for (const [mobId, mob] of Object.entries(work.mobs)) { work.mobs[mobId] = { ...mob, statuses: mob.statuses.filter((status) => !( status.sourceAbilityId === ability.id && status.sourceActorId === PLAYER_AGGRO_ID )) }; } clearedSingleTargetControls = true; } for (const id of candidates) { const mob = work.mobs[id]; if (mob?.characterDefender && ability.effects.some((candidate) => ( candidate.kind === "apply-aura" && candidate.control?.kind === effect.status ))) continue; if (mob) { const next = applyStatus(mob, { ...effect, durationMs: talentAdjustedDuration(effect.durationMs, ability, modifiers), }, ability.id, now, PLAYER_AGGRO_ID); if (state.classId === "rogue" && ability.name === "Kick" && effect.status === "interrupt" && mob.activeCast?.interruptible && !next.activeCast) improvedKickInterruptLanded = true; const kickSilenceDurationMs = state.classId === "rogue" && ability.name === "Kick" && effect.status === "interrupt" ? wow335RogueImprovedKickSilenceDurationMs( work.talentRanks[WOW335_ROGUE_IMPROVED_KICK_TALENT_ID] ?? 0, stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${id}:${now}:wow335-rogue-improved-kick-silence`), ) : 0; work.mobs[id] = kickSilenceDurationMs > 0 ? applyStatus(next, { kind: "status", status: "silence", durationMs: kickSilenceDurationMs, }, ability.id, now, PLAYER_AGGRO_ID) : next; } } } else if (effect.kind === "apply-aura") { const sourceDurationMs = ability.name === "Envenom" && effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG) ? (work.comboPoints + 1) * 1_000 : ability.name === "Rupture" ? Math.max(4, Math.min(8, 3 + work.comboPoints)) * 2_000 : holyAuraFriendly && ability.name === "Holy Aura" && effect.aura.durationMs !== null ? Math.max(1_000, effect.aura.durationMs - 2_000) : effect.aura.durationMs; const durationMs = sourceDurationMs === null ? null : ability.name === "Rupture" || ability.name === "Envenom" && effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG) ? sourceDurationMs : talentAdjustedDuration(sourceDurationMs, ability, modifiers); const sourceAuraBase = ability.dbcSpellId === 48707 && state.classId === "death-knight" ? { ...effect.aura, absorbs: effect.aura.absorbs?.map((absorb) => ({ ...absorb, amount: Math.round(work.maxHealth * absorb.amount), })), } : state.classId === "mage" ? wow335MageFrostWardingArmorAuraEffect( ability, work.talentRanks[WOW335_MAGE_FROST_WARDING_TALENT_ID] ?? 0, effect, ).aura : effect.aura; const classAdjustedAura = state.classId === "shaman" && ability.name === "Earth Shield" ? improvedEarthShieldAuraDefinition( sourceAuraBase, ability, shamanTalentRank(work, "Improved Earth Shield", 3), ) : sourceAuraBase; const sourceAura = state.classId === "death-knight" && ability.dbcSpellId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_SPELL_ID ? wow335DeathKnightMarkOfBloodAuraDefinition(classAdjustedAura) : classAdjustedAura; const excludesLiquidationTarget = state.classId === "rom-warlock" && state.secondaryClassId === "rom-rogue" && ability.dbcSpellId === 498695 && effect.scope === "party" && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "threat" && modifier.value < 0); if (ability.name === "Prayer of Mending") { work.auras = work.auras.filter((aura) => !( aura.sourceId === PLAYER_AURA_ENTITY_ID && isPrayerOfMendingAura(aura) )); } const romWardenWillAttackTargets = ability.source === "runewaker" && ability.dbcSpellId === 493391 && effect.aura.utilityTags?.includes(ROM_WARDEN_WILL_ATTACK_TAG); const recipients = romWardenWillAttackTargets ? affectedMobIds : effect.scope === "party" ? (() => { const partyMembers = usePartyStore.getState().members; const classGroupBlessing = state.classId === "paladin" && ability.name.startsWith("Greater Blessing of "); const selectedClass = classGroupBlessing && targetId ? partyMembers.find((member) => member.id === targetId)?.classId ?? state.classId : state.classId; const partyRecipientIds = [ ...(!classGroupBlessing || state.classId === selectedClass ? [PLAYER_AURA_ENTITY_ID] : []), ...partyMembers .filter((member) => member.health > 0) .filter((member) => !classGroupBlessing || member.classId === selectedClass) .filter((member) => { if (!targeting || (targeting.shape !== "circle" && ability.radius === undefined)) return true; const position = getPartyRuntimePosition(member.id); const radius = targeting.radius ?? ability.radius ?? 0; return Boolean(position && Math.sqrt(distanceSquared(playerOrigin, position)) <= radius && combatHasLineOfSight(playerOrigin, position)); }) .map((member) => member.id)]; const prioritized = targetId && partyRecipientIds.includes(targetId) ? [targetId, ...partyRecipientIds.filter((id) => id !== targetId)] : partyRecipientIds; return targeting?.maximumTargets ? prioritized.slice(0, targeting.maximumTargets) : prioritized; })() : effect.control && affectedMobIds.length ? affectedMobIds : holyAuraFriendly && ability.name === "Holy Aura" && targetId ? [targetId] : effect.recipient === "pet" ? [activeHunterPetId(work.summons, now)].filter((id): id is string => id !== null) : effect.recipient === "caster" ? [PLAYER_AURA_ENTITY_ID] : effectiveTarget === "hostile" ? affectedMobIds : [targetId ?? PLAYER_AURA_ENTITY_ID]; const hitFilteredRecipients = effect.requiresLandedHit && effect.scope !== "party" && effect.recipient !== "caster" && effect.recipient !== "pet" ? recipients.filter((id) => landedDirectHitTargetIdsForCast.has(id)) : recipients; for (const recipientId of hitFilteredRecipients.filter((id) => (!effect.excludeCaster || id !== PLAYER_AURA_ENTITY_ID) && !(excludesLiquidationTarget && targetId && id === targetId) && (!effect.aura.utilityTags?.includes(ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG) || id === PLAYER_AURA_ENTITY_ID || usePartyStore.getState().members.some((member) => member.id === id)))) { const recipientMaxHealth = recipientId === PLAYER_AURA_ENTITY_ID ? work.maxHealth : usePartyStore.getState().members.find((member) => member.id === recipientId)?.maxHealth ?? work.summons.find((summon) => summon.id === recipientId)?.maxHealth ?? work.actors[recipientId]?.maxHealth ?? onlinePlayerRuntimeActor(recipientId)?.maxHealth ?? work.maxHealth; const recipientAura = sourceAura.id === "rom-knight-deadly-wound" && work.mobs[recipientId]?.characterDefender ? { ...sourceAura, durationMs: 5_000 } : ability.classId === "rom-warden" && ability.dbcSpellId === 498201 ? { ...sourceAura, absorbs: sourceAura.absorbs?.map((absorb) => ({ ...absorb, amount: Math.round(recipientMaxHealth * 0.1), })), } : sourceAura; const recipientDurationMs = ability.dbcSpellId === 490275 && work.mobs[recipientId]?.characterDefender ? romPriestVowOfSilenceDurationMs(true) : durationMs; const auraDefinition = auraDefinitionForAbility(recipientAura, ability, recipientDurationMs); if (effect.aura.utilityTags?.includes(ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG)) { work.auras = work.auras.filter((aura) => !( aura.sourceId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG) )); } const previousAuras = work.auras; const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id; if (previousAuras.some((aura) => aura.targetId === recipientId && aura.definition.exclusiveGroup === `gm-party-buff:${family}`)) continue; if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue; const auraResult = applyCombatAura(previousAuras, { definition: auraDefinition, sourceId: PLAYER_AURA_ENTITY_ID, targetId: recipientId, now, }); const startsAtZeroStacks = isRomMageDruidPair({ classId: state.classId, secondaryClassId: state.secondaryClassId, }) && auraDefinition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG) && recipientId === PLAYER_AURA_ENTITY_ID; work.auras = startsAtZeroStacks ? auraResult.auras.map((aura) => aura.instanceId === auraResult.aura.instanceId ? { ...aura, stacks: 0 } : aura) : [...auraResult.auras]; if (ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID) { work.auras = [...completeDeterrenceLinkedAuras(work.auras, ability, PLAYER_AURA_ENTITY_ID, recipientId, now)]; } if (state.classId === "mage" && ability.name === "Presence of Mind" && wow335MageArcanePotencyCanProc( mageArcanePotencyRank, "presence-of-mind", )) { work.auras = [...applyCombatAura(work.auras, { definition: WOW335_MAGE_ARCANE_POTENCY_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } if (!missileBarrageSlowCastChecked && state.classId === "mage" && ability.name === "Slow" && effectiveTarget === "hostile" && effect.aura.disposition === "debuff" && recipientId !== PLAYER_AURA_ENTITY_ID && work.mobs[recipientId] && auraResult.aura.targetId === recipientId) { missileBarrageSlowCastChecked = true; maybeProcMageMissileBarrage( work, ability.name, `${castThreatSource.actorId}:${ability.id}:${recipientId}:${now}:slow`, now, ); } if (ability.name === "Power Word: Shield") { work.auras = [...applyCombatAura(work.auras, { definition: consumesInnerFocus ? { ...WEAKENED_SOUL_AURA, durationMs: Math.max(0, (WEAKENED_SOUL_AURA.durationMs ?? 0) - 10_000) } : WEAKENED_SOUL_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: recipientId, now, }).auras]; } const grantedAbsorb = auraResult.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0); if (grantedAbsorb > 0) { recordCombatEventInWork(work, { occurredAt: now, kind: "absorb", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: recipientId, abilityId: ability.id, school: presentationSchoolForAbility(ability), rawAmount: grantedAbsorb, effectiveAmount: grantedAbsorb, overheal: 0, absorbed: grantedAbsorb, critical: false, }); } applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras); const sourceImmunityGrant = (effect.sourceAuraType === 77 && effect.miscValue === 5) || (effect.sourceAuraType === 147 && effect.miscValue === 1733); if (sourceImmunityGrant) { const purge = purgeNamedEnemyControlShoutAuras(work.auras, recipientId, enemyControlImmunityPurgeProofForAbility(ability), now); work.auras = [...purge.auras]; if (purge.removed.length && recipientId === PLAYER_AURA_ENTITY_ID) { clearedPlayerControl = true; survivingDispelPlayerControl = remainingEnemyControlAfterGrant(work.auras, recipientId, now); } else if (purge.removed.length) clearCompanionControlInWork(work, recipientId, now); } if (auraDefinition.controlImmunities?.length) { if ( recipientId === PLAYER_AURA_ENTITY_ID && state.controlMechanic && auraDefinition.controlImmunities.includes(state.controlMechanic) ) { clearedPlayerControl = true; survivingDispelPlayerControl = remainingEnemyControlAfterGrant(work.auras, recipientId, now); } else if (recipientId !== PLAYER_AURA_ENTITY_ID) { const member = usePartyStore.getState().members.find((candidate) => candidate.id === recipientId); if (member?.controlMechanic && auraDefinition.controlImmunities.includes(member.controlMechanic)) { clearCompanionControlInWork(work, recipientId, now); } } } // If this ability also has an explicit status effect for the same // mechanic, that effect owns the status event. The aura still gets // applied here, but applying its control a second time would consume // diminishing returns twice on one cast. const hasStandaloneControlStatus = effect.control && ability.effects.some((candidate) => ( candidate.kind === "status" && candidate.status === effect.control!.kind )); if (effect.control && !hasStandaloneControlStatus && work.mobs[recipientId]) { const durationMs = auraDefinition.durationMs ?? 1_000; const previousStatuses = work.mobs[recipientId].statuses; work.mobs[recipientId] = applyStatus(work.mobs[recipientId], { kind: "status", status: effect.control.kind as MobStatusKind, durationMs, ...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }), }, `aura:${auraDefinition.id}`, now); if (work.mobs[recipientId].characterDefender) { const applied = work.mobs[recipientId].statuses.find((status) => status.sourceAbilityId === `aura:${auraDefinition.id}`); if (work.mobs[recipientId].statuses === previousStatuses) work.auras = [...previousAuras]; else if (applied) work.auras = work.auras.map((aura) => aura.targetId === recipientId && aura.definition.id === auraDefinition.id ? { ...aura, expiresAt: applied.endsAt } : aura); } } } } else if (effect.kind === "dispel") { const primaryRecipientId = effectiveTarget === "hostile" ? targetId : targetId ?? PLAYER_AURA_ENTITY_ID; const recipientIds = effect.scope === "party" ? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.map((member) => member.id)] : targeting?.shape === "circle" && effectiveTarget !== "hostile" ? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members .filter((member) => member.health > 0) .filter((member) => { const position = getPartyRuntimePosition(member.id); const radius = targeting.radius ?? ability.radius ?? 0; return Boolean(position && Math.sqrt(distanceSquared(playerOrigin, position)) <= radius && combatHasLineOfSight(playerOrigin, position)); }) .map((member) => member.id)] : primaryRecipientId ? [primaryRecipientId] : []; const prioritizedRecipientIds = targetId && recipientIds.includes(targetId) ? [targetId, ...recipientIds.filter((id) => id !== targetId)] : recipientIds; const limitedRecipientIds = targeting?.maximumTargets ? prioritizedRecipientIds.slice(0, targeting.maximumTargets) : recipientIds; for (const recipientId of limitedRecipientIds) { const relationship = effect.relationship === "target" ? (work.mobs[recipientId] ? "hostile" : "friendly") : effect.relationship; const targeted = work.auras.filter((aura) => aura.targetId === recipientId); const untouched = work.auras.filter((aura) => aura.targetId !== recipientId); const result = dispelCombatAuras(targeted, { relationship, categories: effect.categories, maxCount: effect.maxCount, }); work.auras = [...untouched, ...result.auras]; if (result.removed.some((removed) => removed.definition.name.startsWith("Living Bomb"))) { for (const timed of work.effects.filter((entry) => entry.targetId === recipientId && result.removed.some((removed) => removed.sourceId === entry.sourceActorId && abilityById(entry.sourceAbilityId)?.name === "Living Bomb"))) { detonateLivingBombInWork(work, timed, now); work.effects = work.effects.filter((entry) => entry.id !== timed.id); work.auras = work.auras.filter((aura) => !(aura.targetId === recipientId && aura.sourceId === timed.sourceActorId && aura.definition.name.startsWith("Living Bomb"))); } } if (result.removed.length > 0 && work.mobs[recipientId]) { const removedTokens = new Set(result.removed.map((removed) => `aura:${removed.definition.id}`)); work.mobs[recipientId] = { ...work.mobs[recipientId], statuses: work.mobs[recipientId].statuses.filter((status) => !removedTokens.has(status.sourceAbilityId)), auras: work.mobs[recipientId].auras.filter((legacy) => !result.removed.some((removed) => ( removed.definition.id === `enemy:${recipientId}:${legacy.id}` ))), }; } if ( recipientId === PLAYER_AURA_ENTITY_ID && result.removed.some((removed) => removed.definition.id.startsWith("enemy-control:")) ) { clearedPlayerControl = true; survivingDispelPlayerControl = resolveEnemyTriggeredShoutControl(work.auras, recipientId, now); } if ( recipientId !== PLAYER_AURA_ENTITY_ID && result.removed.some((removed) => removed.definition.id.startsWith("enemy-control:")) ) clearCompanionControlInWork(work, recipientId, now); if (effect.mode === "steal") { for (const removed of result.removed) { work.auras = [...applyCombatAura(work.auras, { definition: { ...removed.definition, disposition: "buff" }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } } } else if (effect.kind === "interrupt") { for (const id of affectedMobIds) { const mob = work.mobs[id]; if (mob) { const lockoutMs = ability.dbcSpellId === 490275 ? romPriestVowOfSilenceDurationMs(Boolean(mob.characterDefender)) : effect.lockoutMs; const next = applyStatus(mob, { kind: "status", status: "interrupt", durationMs: lockoutMs, }, ability.id, now); if (state.classId === "rogue" && ability.name === "Kick" && mob.activeCast?.interruptible && !next.activeCast) improvedKickInterruptLanded = true; const silenceDurationMs = mob.activeCast?.interruptible && state.classId === "mage" ? wow335MageImprovedCounterspellSilenceDurationMs( work.talentRanks[WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID] ?? 0, ability.name, ) : 0; let resolved = silenceDurationMs > 0 && next.activeCast === null ? applyStatus(next, { kind: "status", status: "silence", durationMs: silenceDurationMs, }, ability.id, now, PLAYER_AGGRO_ID) : next; const kickSilenceDurationMs = state.classId === "rogue" && ability.name === "Kick" ? wow335RogueImprovedKickSilenceDurationMs( work.talentRanks[WOW335_ROGUE_IMPROVED_KICK_TALENT_ID] ?? 0, stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${id}:${now}:wow335-rogue-improved-kick-silence`), ) : 0; if (kickSilenceDurationMs > 0) { resolved = applyStatus(resolved, { kind: "status", status: "silence", durationMs: kickSilenceDurationMs, }, ability.id, now, PLAYER_AGGRO_ID); } work.mobs[id] = resolved; } } } else if (effect.kind === "pull") { if (ability.target === "friendly" && targetId && effect.toward === "caster") { updatePartyRuntimePosition( targetId, state.playerPosition[0], state.playerPosition[1], state.playerPosition[2], ); } for (const id of affectedMobIds) { const currentPosition = getMobPosition(id); if (!currentPosition) continue; if (effect.toward === "caster") { const dx = currentPosition[0] - state.playerPosition[0]; const dz = currentPosition[2] - state.playerPosition[2]; const length = Math.max(0.001, Math.hypot(dx, dz)); setMobPosition(id, [ state.playerPosition[0] + dx / length * 1.5, currentPosition[1], state.playerPosition[2] + dz / length * 1.5, ]); } } } else if (effect.kind === "execute") { for (const id of affectedMobIds) { const mob = work.mobs[id]; // DBC instant-kill effects include pet sacrifices such as Death Pact. // They must never bypass a boss encounter's health and mechanics. if (!mob || mob.dead || mob.boss) continue; damageMobInWork( state.classId, work, id, Math.max(mob.health, mob.maxHealth * effect.coefficient), now, castThreatSource, damageSchool, ); } } else if (effect.kind === "resource-drain") { const resource = effect.resource ?? activeResourceType; const maximum = work.maxResourcePools[resource] ?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource)); const drainAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount; if (effect.recipient === "caster") { const currentResource = work.resourcePools[resource] ?? 0; const actualDrain = Math.min(currentResource, drainAmount); work.resourcePools[resource] = Math.max(0, currentResource - drainAmount); if (resource === "nature-power") applyRomDruidMageInspirationSpend(actualDrain); if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource; } else if (ability.target === "hostile") { let drainedFromPlayers = 0; let hasPlayerTarget = false; for (const id of affectedMobIds) { const mob = work.mobs[id]; if (!mob?.characterResources) continue; hasPlayerTarget = true; const current = mob.characterResources[resource] ?? 0; const requested = effect.percentage ? mob.maxMana * effect.amount / 100 : effect.amount; const drained = Math.min(current, requested); drainedFromPlayers += drained; work.mobs[id] = { ...mob, characterResources: { ...mob.characterResources, [resource]: current - drained } }; if (effect.burn && drained > 0) damageMobInWork(state.classId, work, id, drained, now, castThreatSource, damageSchool); } const returned = effect.burn ? 0 : hasPlayerTarget ? drainedFromPlayers : drainAmount; if (returned > 0) { work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + returned, 0, maximum); if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource; } } else { work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - drainAmount); if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource; } } else if (effect.kind === "threat") { const threatTargets = ability.name === "Soulshatter" ? affectedMobIds : affectedMobIds.length ? affectedMobIds : Object.keys(work.mobs); for (const id of threatTargets) { const mob = work.mobs[id]; if (!mob || mob.dead) continue; const threatActorId = effect.recipient === "ability-target" && targetId && !work.mobs[targetId] ? targetId : castThreatSource.actorId; const currentThreat = mob.threatByActor[threatActorId] ?? 0; const nextThreat = effect.mode === "redirect" ? 0 : effect.mode === "percent" ? currentThreat * Math.max(0, 1 + effect.amount / 100) : Math.max(0, currentThreat + effect.amount); work.mobs[id] = { ...mob, threatByActor: { ...mob.threatByActor, [threatActorId]: nextThreat, ...(effect.mode === "redirect" && targetId && !work.mobs[targetId] ? { [targetId]: (mob.threatByActor[targetId] ?? 0) + currentThreat } : {}), }, }; } } else if (effect.kind === "movement") { if (ability.id === "wow335-mage-blink") { const spatialYaw = combatSpatialActor(PLAYER_AURA_ENTITY_ID)?.yaw; const gameYaw = useGameStore.getState().cameraYaw; const yaw = typeof spatialYaw === "number" && Number.isFinite(spatialYaw) ? spatialYaw : Number.isFinite(gameYaw) ? gameYaw : 0; const destination = forwardMovementDestination(state.playerPosition, yaw, effect.distance ?? 0); if (destination && Math.hypot( destination[0] - state.playerPosition[0], destination[2] - state.playerPosition[2], ) > 0.05) { playerPositionAfterEffects = destination; playerRelocationAfterEffects = { position: destination, yaw }; } } else if (ability.dbcSpellId === 54785 && targetPosition) { const dx = targetPosition[0] - playerOrigin[0]; const dz = targetPosition[2] - playerOrigin[2]; const length = Math.max(0.001, Math.hypot(dx, dz)); playerPositionAfterEffects = [ targetPosition[0] - dx / length * 1.5, targetPosition[1], targetPosition[2] - dz / length * 1.5, ]; } else if (ability.dbcSpellId === 490344 && targetId) { const target = getMobRuntimeTransform(targetId); if (target) playerPositionAfterEffects = [ 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] - state.playerPosition[0]; const dz = targetPosition[2] - state.playerPosition[2]; const length = Math.max(0.001, Math.hypot(dx, dz)); playerPositionAfterEffects = [ targetPosition[0] - dx / length * 1.5, targetPosition[1], targetPosition[2] - dz / length * 1.5, ]; } else if (effect.movement === "sanctuary") { for (const [id, mob] of Object.entries(work.mobs)) { const threatByActor = { ...mob.threatByActor, [castThreatSource.actorId]: 0 }; work.mobs[id] = { ...mob, threatByActor }; } } else if (effect.movement === "knockback") { for (const id of affectedMobIds) { const position = getMobPosition(id); if (!position) continue; const dx = position[0] - state.playerPosition[0]; const dz = position[2] - state.playerPosition[2]; const length = Math.max(0.001, Math.hypot(dx, dz)); setMobPosition(id, [position[0] + dx / length * 4, position[1], position[2] + dz / length * 4]); } } else if (effect.toward === "away") { const referenceId = state.selectedTargetId ?? nearbyMobRuntimeIds(state.playerPosition, 40).find((id) => !work.mobs[id]?.dead) ?? null; const reference = referenceId ? getMobPosition(referenceId) : null; const dx = reference ? state.playerPosition[0] - reference[0] : 0; const dz = reference ? state.playerPosition[2] - reference[2] : 1; const length = Math.max(0.001, Math.hypot(dx, dz)); playerPositionAfterEffects = [ state.playerPosition[0] + dx / length * 8, state.playerPosition[1], state.playerPosition[2] + dz / length * 8, ]; } else if (targetPosition) { playerPositionAfterEffects = [targetPosition[0], targetPosition[1], targetPosition[2]]; } } else if (effect.kind === "dispel-mechanic") { const mechanicStatus: Partial> = { 1: "charm", 3: "disarm", 5: "fear", 7: "root", 9: "silence", 10: "sleep", 11: "slow", 12: "stun", }; for (const id of affectedMobIds) { const mob = work.mobs[id]; if (!mob) continue; const wanted = mechanicStatus[effect.mechanic]; let removed = 0; work.mobs[id] = { ...mob, statuses: mob.statuses.filter((status) => { if (removed >= effect.maxCount || (wanted && status.kind !== wanted)) return true; removed += 1; return false; }), }; } } else if (effect.kind === "pet") { if (["summon", "call", "recall", "resurrect", "tame"].includes(effect.action)) { const isHunterPet = state.classId === "hunter" && ability.classId === "hunter"; const existingPet = (isHunterPet ? work.summons.find((summon) => summon.hunterPet && summon.ownerActorId === PLAYER_AGGRO_ID) : undefined) ?? work.summons.find((summon) => summon.id.startsWith("player-pet-")); const hunterPetOwnerBaseStats = isHunterPet ? playerCharacterStats( work.classId, work.progression.level, work.gearStats, work.raceId, work.secondaryClassId, work.talentModifiers, work.gender, work.statRuleset, ) : undefined; const hunterPetOwnerStats = hunterPetOwnerBaseStats ? applyAuraStats(hunterPetOwnerBaseStats, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID)) : undefined; const hunterPetProfile = hunterPetOwnerStats ? wow335HunterPetProfileForLevel(work.progression.level, state.hunterPetFamily, hunterPetOwnerStats) : undefined; const hunterPetEffectiveStats = hunterPetProfile?.effectiveStats; const tameTarget = effect.action === "tame" && targetId ? work.mobs[targetId] : undefined; if (isHunterPet && effect.action === "resurrect" && existingPet && existingPet.health <= 0) { work.summons = work.summons.map((summon) => summon.id === existingPet.id ? { ...summon, ...(hunterPetProfile ? { name: wow335HunterPetFamilyName(hunterPetProfile.family) } : {}), maxHealth: hunterPetEffectiveStats?.maxHealth ?? summon.maxHealth, health: Math.max(1, Math.round((hunterPetEffectiveStats?.maxHealth ?? summon.maxHealth) * 0.15)), attackDamage: hunterPetEffectiveStats?.averageAutoAttackDamage ?? summon.attackDamage, position: playerOrigin, targetId: hunterPetProfile ? null : targetId, nextActionAt: now + 1_000, ...(hunterPetProfile ? initialHunterPetCombatRuntime(hunterPetProfile, now) : {}), } : summon); } else if (isHunterPet && effect.action === "call" && existingPet && existingPet.health > 0) { work.summons = work.summons.map((summon) => summon.id === existingPet.id ? { ...summon, ...(hunterPetProfile ? { name: wow335HunterPetFamilyName(hunterPetProfile.family), maxHealth: hunterPetEffectiveStats?.maxHealth ?? summon.maxHealth, health: Math.min(summon.health, hunterPetEffectiveStats?.maxHealth ?? summon.maxHealth), attackDamage: hunterPetEffectiveStats?.averageAutoAttackDamage ?? summon.attackDamage, hunterPet: hunterPetProfile, } : {}), position: playerOrigin, targetId: null, } : summon); } else if (!existingPet) { summonSequence += 1; work.summons.push({ id: `player-pet-${summonSequence}`, sourceAbilityId: ability.id, creatureId: tameTarget?.serverEntry ?? effect.creatureId ?? 1, targetId: isHunterPet ? null : targetId, expiresAt: Number.MAX_SAFE_INTEGER, kind: "pet", name: isHunterPet && hunterPetProfile ? (hunterPetProfile.family === "wolf" ? tameTarget?.name ?? "Wolf" : wow335HunterPetFamilyName(hunterPetProfile.family)) : ability.name, ownerActorId: PLAYER_AGGRO_ID, health: hunterPetEffectiveStats?.maxHealth ?? Math.max(1, Math.round(work.maxHealth * 0.5)), maxHealth: hunterPetEffectiveStats?.maxHealth ?? Math.max(1, Math.round(work.maxHealth * 0.5)), position: playerOrigin, nextActionAt: now + 1_000, pulseIntervalMs: 2_000, attackDamage: hunterPetEffectiveStats?.averageAutoAttackDamage ?? Math.max(1, Math.round(playerCombatPower(work.progression.level) * 0.35)), ...initialSummonCombatRuntime(), ...(hunterPetProfile ? initialHunterPetCombatRuntime(hunterPetProfile, now) : {}), exclusiveGroup: "combat-pet", }); } } else if (effect.action === "dismiss") { const dismissedPets = new Set(work.summons .filter((summon) => summon.id.startsWith("player-pet-") || (summon.hunterPet && summon.ownerActorId === PLAYER_AGGRO_ID)) .map((summon) => summon.id)); work.summons = work.summons.filter((summon) => !dismissedPets.has(summon.id)); work.auras = work.auras.filter((aura) => !( dismissedPets.has(aura.targetId) && aura.definition.utilityTags?.some((tag) => ( tag === WOW335_HUNTER_PET_PROWL_TAG || tag === WOW335_HUNTER_PET_SHELL_SHIELD_TAG )) )); } } else if (effect.kind === "totem") { work.summons = work.summons.filter((summon) => !/totem/i.test(abilityById(summon.sourceAbilityId)?.name ?? "")); } else if (effect.kind === "rune") { work.deathKnightRunes = effect.runeType === "blood" && effect.convertToDeathMs !== undefined ? activateDeathKnightBloodTapRune(work.deathKnightRunes, now, effect.convertToDeathMs) : activateDeathKnightRunes(work.deathKnightRunes, effect.count); } else if (effect.kind === "cooldown-reset") { cooldownAdjustments.push(effect); } else if (effect.kind === "trigger-spell") { if (!effect.expanded && effect.spellId !== ability.dbcSpellId) { executeTriggeredSpellInWork( work, effect.spellId, PLAYER_AURA_ENTITY_ID, targetId, now, 0, false, ability, ); } } else if (effect.kind === "utility") { if (effect.action === "create-item") { if (/water|mana|spellstone/i.test(ability.name)) { const restored = Math.max(1, Math.round(work.maxResource * 0.25)); work.resource = clamp(work.resource + restored, 0, work.maxResource); work.resourcePools[activeResourceType] = work.resource; } else if (/food|refreshment|health|firestone/i.test(ability.name)) { work.totalHealing += healPlayerInWork(work, Math.max(1, Math.round(work.maxHealth * 0.25))); } } else if (effect.action === "pickpocket" && targetId && work.mobs[targetId]) { abilityFeedback = { kind: "loot", message: `Pick Pocket succeeds on ${work.mobs[targetId].name}.`, }; } else if (effect.action === "portal") { summonSequence += 1; work.summons.push({ id: `player-utility-${summonSequence}`, sourceAbilityId: ability.id, creatureId: effect.miscValue, targetId: null, expiresAt: now + Math.max(30_000, effect.durationMs), kind: "summon", name: ability.name, ownerActorId: PLAYER_AGGRO_ID, health: 1, maxHealth: 1, position: playerOrigin, nextActionAt: Number.MAX_SAFE_INTEGER, pulseIntervalMs: 0, attackDamage: 0, ...initialSummonCombatRuntime(), }); } else if (effect.action === "class-script" && ability.source === "runewaker" && ability.dbcSpellId === 494321 && work.classId === "rom-druid" && work.secondaryClassId === "rom-rogue" && effect.triggerSpellId === 494024) { const poisonAbility = abilityById("rom-druid-rom-rogue-elite-25"); const poisonEffect = poisonAbility?.effects.find((candidate): candidate is Extract => ( candidate.kind === "dot" )); if (poisonAbility && poisonEffect) { for (const poisonTargetId of affectedMobIds) { work.effects = work.effects.filter((active) => !(active.kind === "dot" && active.targetId === poisonTargetId && active.tag === poisonEffect.tag)); work.effects.push(timedEffect( "dot", poisonAbility.id, castThreatSource.actorId, poisonTargetId, poisonEffect, poisonEffect.ticks, poisonEffect.intervalMs, now, poisonEffect.tag, "shadow", work.gearStats.spellPenetration, )); } } } else if (effect.action === "class-script" && ability.name === "Tricks of the Trade" && targetId) { const durationMs = Math.max(1_000, effect.durationMs); const targetAura = { id: "wow335:rogue:tricks-damage-buff", name: ability.name, description: "Increases damage dealt by 100% while receiving the Rogue's threat.", disposition: "buff" as const, durationMs, maxStacks: 1, stackBehavior: "refresh" as const, modifiers: [{ kind: "damage" as const, direction: "dealt" as const, operation: "percent" as const, value: 1 }], utilityTags: ["wow335-rogue-tricks-damage-buff"], }; const recipientAura = { id: "wow335:rogue:tricks-threat-recipient", name: ability.name, description: "Redirects threat generated by the Rogue to the selected party member.", disposition: "buff" as const, durationMs, maxStacks: 1, stackBehavior: "refresh" as const, exclusiveGroup: `${TRICKS_THREAT_REDIRECT_GROUP_PREFIX}${targetId}`, utilityTags: ["wow335-rogue-tricks-threat-routing"], }; work.auras = work.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-rogue-tricks-threat-routing") )); work.auras = [...applyCombatAura(work.auras, { definition: auraDefinitionForAbility(targetAura, ability, durationMs), sourceId: PLAYER_AURA_ENTITY_ID, targetId, now, }).auras]; work.auras = [...applyCombatAura(work.auras, { definition: auraDefinitionForAbility(recipientAura, ability, durationMs), sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } else if (effect.action === "class-script" && ability.name === "Shadowstep" && targetId) { executeTriggeredSpellInWork(work, 36563, PLAYER_AURA_ENTITY_ID, targetId, now, 0); executeTriggeredSpellInWork(work, 44373, PLAYER_AURA_ENTITY_ID, null, now, 0); const target = getMobRuntimeTransform(targetId); if (target) playerPositionAfterEffects = [ target.position[0] - Math.sin(target.yaw) * 1.5, target.position[1], target.position[2] - Math.cos(target.yaw) * 1.5, ]; } else if (effect.action === "class-script" && ability.name === "Killing Spree" && targetId) { const durationMs = Math.max(500, effect.durationMs); const spreeAura = auraDefinitionForAbility({ id: "wow335:rogue:killing-spree-damage", name: ability.name, description: "Increases all damage dealt by 20% during Killing Spree.", disposition: "buff", durationMs, maxStacks: 1, stackBehavior: "refresh", modifiers: [{ kind: "damage", direction: "dealt", operation: "percent", value: 0.2 }], utilityTags: ["wow335-rogue-killing-spree"], }, ability, durationMs); work.auras = [...applyCombatAura(work.auras, { definition: spreeAura, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras].map((aura) => aura.sourceId === PLAYER_AURA_ENTITY_ID && aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-rogue-killing-spree") ? { ...aura, expiresAt: now + durationMs + 1 } : aura); const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: state.classId, secondaryClassId: state.secondaryClassId, specializationId: state.specializationId, role: state.gearRole, level: state.level, }); const hands: (InventoryItem | null)[] = [loadout["main-hand"], loadout["off-hand"]] .map((item) => item?.weaponType ? item : null); const attacker = playerCharacterStats( state.classId, work.progression.level, work.gearStats, work.raceId, work.secondaryClassId, work.talentModifiers, work.gender, work.statRuleset, ); const handSnapshots: Wow335KillingSpreeWeaponSnapshot[] = hands.flatMap((weapon, index) => { const weaponStats = weapon?.stats.meleeWeapon ?? work.gearStats.meleeWeapon; return weaponStats ? [{ hand: index === 0 ? "main-hand" : "off-hand", ...(weapon?.weaponType ? { weaponType: weapon.weaponType } : {}), damageMin: weaponStats.damageMin, damageMax: weaponStats.damageMax, speedMs: weaponStats.speedMs, }] : []; }); const targetIds = [...new Set([targetId, ...nearbyMobRuntimeIds(playerOrigin, 10)])] .filter((mobId) => { const mob = work.mobs[mobId]; const position = getMobPosition(mobId); return Boolean(mob && !mob.dead && mob.health > 0 && position && distanceSquared(playerOrigin, position) <= 100 && combatHasLineOfSight(playerOrigin, position)); }); if (targetIds.length > 0) { const window = createWow335KillingSpreeWindow({ ownerActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, role: castThreatSource.role ?? state.gearRole, now, targetIds, hands: handSnapshots, attackPower: attacker.attackPower, fallbackDamage: playerCombatPower(work.progression.level) * 0.35, }); work.killingSpreeWindows = [ ...(work.killingSpreeWindows ?? state.killingSpreeWindows) .filter((candidate) => candidate.ownerActorId !== PLAYER_AURA_ENTITY_ID), window, ]; } } else if (effect.action === "class-script" && ability.name === "Pestilence" && targetId) { const diseaseTags = new Set(["blood-plague", "frost-fever"]); const diseases = work.effects.filter((entry) => ( entry.kind === "dot" && entry.targetId === targetId && entry.sourceActorId === PLAYER_AGGRO_ID && entry.tag !== undefined && diseaseTags.has(entry.tag) && entry.remainingTicks > 0 )); let didSpreadDisease = false; for (const recipientId of affectedMobIds) { if (recipientId === targetId) continue; didSpreadDisease = diseases.length > 0 || didSpreadDisease; for (const disease of diseases) { work.effects = work.effects.filter((entry) => !( entry.kind === "dot" && entry.targetId === recipientId && entry.sourceActorId === disease.sourceActorId && entry.tag === disease.tag )); effectSequence += 1; work.effects.push({ ...disease, id: `${ability.id}:${effectSequence}`, targetId: recipientId, }); } } if (didSpreadDisease && state.classId === "death-knight") { // The generated Pestilence action has a class-script utility // effect, not a damage packet. A successful disease transfer is // its landed event for Reaping; the per-cast guard prevents one // proc for every destination enemy. attemptDeathKnightReapingOnLandedTrigger(targetId); } } else if (effect.action === "class-script" && ability.name === "Divine Intervention") { work.health = 1; work.progression.health = 1; } else if (effect.action === "class-script" && ability.id === "rom-rogue-rom-warden-elite-60") { const revival = work.auras.find((aura) => ( aura.sourceId === PLAYER_AURA_ENTITY_ID && aura.targetId === PLAYER_AURA_ENTITY_ID && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("rom-revival-charge") )); work.totalHealing += healPlayerInWork(work, romWardenRevivalNormalHeal({ stacks: revival?.stacks ?? 0, targetMaxHealth: work.maxHealth, })); if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId); } } } if (state.classId === "death-knight" && ability.id === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID) { // Keep the sourced 12-second activation without fabricating a separate // summon's health, attack power, or weapon. Eligible hits below mirror // the owner's resolved attack payload instead. work.summons = work.summons.filter((summon) => !( summon.ownerActorId === PLAYER_AGGRO_ID && summon.sourceAbilityId === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID )); work.auras = [...applyCombatAura(work.auras, { definition: WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } if (improvedKickInterruptLanded) { const kickBonus = wow335RogueImprovedKickAuraEffectForRank( work.talentRanks[WOW335_ROGUE_IMPROVED_KICK_TALENT_ID] ?? 0, ); if (kickBonus) { work.auras = [...applyCombatAura(work.auras, { definition: kickBonus.aura, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; const currentEnergy = work.resourcePools.energy ?? 0; const maximumEnergy = work.maxResourcePools.energy ?? defaultResourceMaximum("energy"); const restoredEnergy = Math.max(0, Math.min(40, maximumEnergy - currentEnergy)); work.resourcePools.energy = currentEnergy + restoredEnergy; if (resourceProfileForClass(work.classId).type === "energy") work.resource = currentEnergy + restoredEnergy; if (restoredEnergy > 0) { recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, rawAmount: 40, effectiveAmount: restoredEnergy, overheal: 40 - restoredEnergy, absorbed: 0, critical: false, }); } } } if (state.classId === "hunter" && ability.name === "Bestial Wrath") { const talentRank = Math.min(2, Math.max(0, Math.trunc(work.talentRanks["talent-1803"] ?? 0))); const petId = activeHunterPetId(work.summons, now); const generatedMechanics = beastWithinRankMechanics(talentRank); const procChance = generatedMechanics?.procChance ?? 0; if (petId && procChance > 0 && stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${petId}:${now}:the-beast-within`) < procChance) { const catalogPassive = work.abilities.find((candidate) => candidate.name === "The Beast Within"); const passive = catalogPassive ? learnedAbilityForBuild(catalogPassive, work.progression.level, work.talentRanks) : null; for (const effect of passive?.effects ?? []) { if (!passive || effect.kind !== "apply-aura") continue; const previousAuras = work.auras; work.auras = [...applyCombatAura(previousAuras, { definition: auraDefinitionForAbility(effect.aura, passive), sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; applyAuraHealthTransitionInWork(work, PLAYER_AURA_ENTITY_ID, previousAuras, work.auras); } } } if (consumesWarlockNightfall && warlockNightfallAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== warlockNightfallAura.instanceId); } // Arcane Blast's triggered child aura persists through a non-Arcane Blast // Arcane cast, boosting that spell, then is consumed on successful resolve. // Channel ticks happen before their completingCast reaches this point. if (ability.name !== "Arcane Blast" && spellSchool === "arcane" && ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "dot")) { work.auras = work.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-arcane-blast-stack") )); } const consumedRuneWakerTags = ability.id === "rom-mage-primary-55" ? ["rom-charged"] : ability.id === "rom-mage-primary-56" ? ["rom-static-field-charge"] : holyAuraFriendly && ability.name === "Holy Aura" ? ["rom-holy-salvation-candle"] : []; if (consumedRuneWakerTags.length) { work.auras = work.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && consumedRuneWakerTags.some((tag) => aura.definition.utilityTags?.includes(tag)) )); } if (state.classId === "rom-priest" && state.secondaryClassId === "rom-knight" && ability.source === "runewaker" && ability.dbcSpellId === 490261) { work.auras = work.auras.flatMap((aura) => { if (aura.targetId !== PLAYER_AURA_ENTITY_ID || !aura.definition.utilityTags?.includes(ROM_PRIEST_KNIGHT_HOLY_POWER_HEAL_USES_TAG)) return [aura]; return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; }); } if (divineFavorReady) { work.auras = work.auras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal"))); } if (state.classId === "warrior" && ability.name === "Execute" && targetId && (work.talentRanks["talent-1542"] ?? 0) >= 3) { const executeTarget = state.mobs[targetId]; if (executeTarget && executeTarget.health / Math.max(1, executeTarget.maxHealth) < 0.2) { work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335:warrior:improved-execute-critical", name: "Improved Execute", description: "Using Execute below 20% health grants 10% melee critical strike chance for 5 seconds, stacking up to 3 times.", disposition: "buff", durationMs: 5_000, maxStacks: 3, stackBehavior: "add", modifiers: [{ kind: "stat", stat: "critical-strike", operation: "flat", value: 10 }], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } if (state.classId === "priest") { const painAndSufferingRank = Math.min(3, Math.max(0, Math.trunc(work.talentRanks["talent-1909"] ?? 0))); if (painAndSufferingRank > 0 && targetId && WOW335_PRIEST_PAIN_AND_SUFFERING_CASTS.has(ability.name)) { const shadowWordPain = work.abilities.find((candidate) => candidate.name === "Shadow Word: Pain") ?? null; work.effects = [...refreshPriestPainAndSufferingDot( work.effects, PLAYER_AGGRO_ID, targetId, ability, painAndSufferingRank, shadowWordPain, now, )]; } if (priestSerendipityTriggered) { work.auras = work.auras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-priest-serendipity") && aura.expiresAt !== null && aura.expiresAt <= now)); applyPriestSelfAuraInWork(work, PRIEST_SERENDIPITY_AURA, now); } const holyConcentrationRank = ["Flash Heal", "Greater Heal", "Binding Heal", "Circle of Healing"].includes(ability.name) ? priestTalentRank(work.talentRanks, "Holy Concentration", 3) : 0; if (holyConcentrationRank > 0) { maybeExtendPriestRenewInWork( work, holyConcentrationRank, playerOrigin, `${PLAYER_AGGRO_ID}:${ability.id}:${targetId ?? PLAYER_AURA_ENTITY_ID}:${now}`, now, ); } } if (isRomChampionDarkEnergyStrike && targetId) { const runeSiphonBase = abilitiesForCharacter(state.classId, state.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, work.progression.level, work.talentRanks) : null; if (romChampionDarkEnergyStrikeShouldResetRuneSiphon({ characterClassId: state.classId, secondaryClassId: state.secondaryClassId, ability, runeSiphonLearned: learnedRuneSiphon !== null, singleTargetCast: affectedMobIds.length === 1 && affectedMobIds[0] === targetId && completingCast?.mode !== "channel", group0Survived: romChampionDarkEnergyStrikeGroup0SurvivedTargets.has(targetId), roll: stableCombatUnit( `${PLAYER_AGGRO_ID}:${ability.id}:${targetId}:${now}:rom-champion-dark-energy-strike-rune-siphon`, ), }) && learnedRuneSiphon) { work.cooldowns[learnedRuneSiphon.id] = 0; } } const deathchillAbilityNames = ["Icy Touch", "Howling Blast", "Frost Strike", "Obliterate"]; if (hasHostileEffect && ability.name !== "Cold Blood") { work.auras = work.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("next-offensive-critical") && (!aura.definition.utilityTags.includes("wow335-deathchill-next-critical") || (state.classId === "death-knight" && deathchillAbilityNames.includes(ability.name))) )); } const nerveGearPassiveSource = state.classId === "rom-rogue" && state.secondaryClassId === "rom-champion" ? work.abilities.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) : undefined; const nerveGearPassive = nerveGearPassiveSource ? learnedAbilityForBuild(nerveGearPassiveSource, work.progression.level, work.talentRanks) : null; if (nerveGearPassive && romRogueChampionNerveGearShouldTrigger( state.classId, state.secondaryClassId, ability, true, )) { const effect = romRogueChampionNerveGearEffect(); if (effect.kind === "apply-aura") { work.auras = [...applyCombatAura(work.auras, { definition: effect.aura, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } work.auras = [...applyCoaGuardianParagonEvent(work.auras, { id: PLAYER_AURA_ENTITY_ID, classId: work.classId, talentRanks: work.talentRanks, }, { type: "cast", ability, result: "success" }, now).auras]; evaluateProcsInWork(work, { trigger: "cast", subjectId: PLAYER_AURA_ENTITY_ID, otherId: targetId ?? undefined, abilityId: ability.id, }, now); recordCombatEventInWork(work, { occurredAt: now, kind: "cast", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: targetId ?? PLAYER_AURA_ENTITY_ID, abilityId: ability.id, school: spellSchool, rawAmount: 0, effectiveAmount: 0, overheal: 0, absorbed: 0, critical: false, outcome: "completed", }); if (romMageWarriorMeditationPathCanProc( state.classId, state.secondaryClassId, romMageWarriorMeditationPathPassiveSpellIds.includes(ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID), ability, ) && !romMageWarriorMeditationPathHasState( work.auras, PLAYER_AURA_ENTITY_ID, ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, now, )) { work.auras = [...applyCombatAura(work.auras, { definition: ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_EFFECT.aura, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; work.auras = [...applyCombatAura(work.auras, { definition: ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_EFFECT.aura, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } if (state.classId === "rogue") { maybeProcRogueCastTalentsInWork( work, ability, targetId, state.comboPoints, state.comboTargetId, now, ); } const rimeRank = state.classId === "death-knight" ? Math.max(0, Math.min(3, Math.trunc(work.talentRanks["talent-1992"] ?? 0))) : 0; if (rimeRank > 0 && ability.name === "Obliterate" && targetId && wow335DeathKnightRimeProcSucceeds( rimeRank, stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${targetId}:${now}:wow335-death-knight-rime`), )) { const howlingBlast = work.abilities.find((candidate) => candidate.name === "Howling Blast"); if (howlingBlast) { work.cooldowns[howlingBlast.id] = now; work.auras = [...applyCombatAura(work.auras, { definition: { id: "wow335-death-knight-rime-ready", name: "Rime", description: "Your next Howling Blast consumes no runes.", disposition: "buff", durationMs: null, maxStacks: 1, stackBehavior: "replace", utilityTags: [WOW335_DEATH_KNIGHT_RIME_READY_TAG], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } if (state.classId === "warrior" && targetId) { applyWarriorChargeTalentEffectsInWork(work, ability, now); } if (state.classId === "druid") { const feralInstinctRank = Math.min(3, Math.max(0, Math.trunc(state.talentRanks["talent-799"] ?? 0))); const sourcedDurationMs = ability.effects .filter((effect): effect is Extract => effect.kind === "apply-aura") .reduce((duration, effect) => Math.max(duration, effect.aura.durationMs ?? 0), 0); if (ability.name === "Tiger's Fury") { const aura = wow335DruidFeralInstinctTigerAura(feralInstinctRank, sourcedDurationMs); if (aura) applyDruidSelfAura(work, aura, now); } else if (ability.name === "Enrage") { const aura = wow335DruidFeralInstinctEnrageAura(feralInstinctRank, sourcedDurationMs); if (aura) applyDruidSelfAura(work, aura, now); } } // Judgement of Light restores 2% health when the marked target is struck. if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) { const healed = healPlayerInWork(work, talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability)); work.totalHealing += healed; addHealingThreatInWork(work, castThreatSource, healed, now); } if (state.classId === "rogue" && ability.name === "Riposte") { work.auras = work.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-rogue-riposte-ready") )); } if (state.classId === "hunter" && ability.name === "Counterattack") { work.auras = work.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready") )); } const cooldowns: Record = { ...work.cooldowns }; if (cooldownMs > 0 && completingCast?.mode !== "channel") cooldowns[ability.id] = now + cooldownMs; if (ability.cooldownGroup && completingCast?.mode !== "channel" && !consumesHunterLockAndLoad) { for (const grouped of state.abilities.filter((entry) => entry.cooldownGroup === ability.cooldownGroup)) { const groupedReverberationReductionMs = state.classId === "shaman" && grouped.cooldownGroup === "shaman-shock" ? shamanTalentRankFromRanks(state.talentRanks, "Reverberation", 3) * 500 : 0; const groupedCooldownMs = Math.max(0, talentAdjustedCooldown(grouped, modifiers) - groupedReverberationReductionMs); cooldowns[grouped.id] = now + Math.max(cooldownMs, groupedCooldownMs); } } if (consumesHunterLockAndLoad) cooldowns[ability.id] = 0; for (const adjustment of cooldownAdjustments) { const candidates = cooldownResetCandidates( state.abilities, ability.id, adjustment.abilityName, adjustment.excludeAbilityNames, ); for (const candidate of candidates) { if (adjustment.mode === "reset") cooldowns[candidate.id] = 0; else { const readyAt = cooldowns[candidate.id] ?? now; const reduction = adjustment.amountPercent !== undefined ? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, adjustment.amountPercent)) : adjustment.amountMs ?? 0; cooldowns[candidate.id] = Math.max(now, readyAt - reduction); } } } if (state.classId === "priest" && ability.id === "priest-flash-heal" && (work.talentRanks["talent-1773"] ?? 0) > 0) { const innerFocus = work.abilities.find((candidate) => candidate.name === "Inner Focus"); if (innerFocus && (cooldowns[innerFocus.id] ?? 0) > now) { cooldowns[innerFocus.id] = Math.max(now, cooldowns[innerFocus.id] - 2_000); } } const burstReset = romFireLightningBurstReset( ability, Boolean(targetId && work.mobs[targetId] && !work.mobs[targetId].dead && work.mobs[targetId].health > 0), aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID).some((aura) => aura.definition.utilityTags?.includes(ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG)), ); if (burstReset.reset) { cooldowns[ability.id] = 0; work.auras = [...applyCombatAura(work.auras, { definition: { id: "rom-fire-lightning-burst-reset-guard", name: "Fire Lightning Burst reset guard", disposition: "buff", durationMs: burstReset.guardDurationMs, utilityTags: [ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } if (completingCast?.mode === "channel" && state.classId === "rom-priest" && state.secondaryClassId === "rom-champion") { const rageControlGain = romPriestChampionRageControlGain( learnedRomPriestChampionPassiveSpellIds.includes(498610), { kind: "channel", durationMs: Math.max(0, Math.min(now, completingCast.completesAt) - completingCast.startedAt), }, ); if (rageControlGain > 0) { const rage = gainRomRage( state.classId, state.secondaryClassId, rageControlGain, work.resource, work.maxResource, work.resourcePools, work.maxResourcePools, ); work.resource = rage.resource; work.resourcePools = rage.resourcePools; } } if (consumesShamanNaturesGuardianCrit) { work.auras = [...wow335ShamanNaturesGuardianConsumeNextCrit(work.auras, PLAYER_AURA_ENTITY_ID)]; } const primaryCombatResult = work.combatResults.length === 1 ? work.combatResults[0] : null; const levelMessage = work.progression.levelsGained > 0 ? levelUpMessage(state.classId, state.level, work.progression.level) : primaryCombatResult ? `${ability.name} ${combatResultDescription(primaryCombatResult)}.` : `${ability.name}${work.totalDamage ? ` dealt ${work.totalDamage}` : ""}${work.totalHealing ? ` healed ${work.totalHealing}` : ""}.`; const activeSummonIds = new Set(work.summons.map((summon) => summon.id)); for (const [actorId, actor] of Object.entries(work.actors)) { if (["pet", "totem", "summon"].includes(actor.kind) && !activeSummonIds.has(actorId)) delete work.actors[actorId]; } for (const summon of work.summons) { work.actors[summon.id] = combatActorFromSummon(summon, work.progression.level, work.auras); } set({ level: work.progression.level, xp: work.progression.xp, xpToNext: work.progression.xpToNext, health: work.progression.levelsGained > 0 ? work.progression.health : work.health, maxHealth: work.progression.maxHealth, shield: work.shield, shieldExpiresAt: work.shieldExpiresAt, resource: clamp(work.resource, 0, work.maxResource), maxResource: work.maxResource, resourcePools: { ...work.resourcePools, [activeResourceType]: clamp(work.resource, 0, work.maxResource) }, maxResourcePools: work.maxResourcePools, lastResourceSpentAt: work.lastResourceSpentAt, deathKnightRunes: work.deathKnightRunes, talentModifiers: work.talentModifiers.filter((modifier) => modifier.sourceTalentId !== "talent-1854:active"), comboPoints: work.comboPoints, comboTargetId: work.comboTargetId, mobs: work.mobs, auras: work.auras, killingSpreeWindows: work.killingSpreeWindows ?? state.killingSpreeWindows, combatEvents: work.combatEvents, romPriestHolyCandleLastChargeAt: work.romPriestHolyCandleLastChargeAt ?? state.romPriestHolyCandleLastChargeAt, actors: work.actors, effects: work.effects.length === state.effects.length && work.effects.every((effect, index) => effect === state.effects[index]) ? state.effects : work.effects, summons: work.summons, areaEffects: work.areaEffects, lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult, cooldowns, globalCooldownEndsAt: completingCast || suddenDoomFreeDeathCoil || gcdMs <= 0 ? state.globalCooldownEndsAt : now + gcdMs, activeCast: null, ...autoAttackUpdate, ...autoShotUpdate, playerAnimationEvent: nextAnimationEvent("ability-release", ability.id), selectedTargetId: ability.target === "hostile" ? targetId : state.selectedTargetId, playerPosition: playerPositionAfterEffects, ...(clearedPlayerControl ? { controlledUntil: survivingDispelPlayerControl?.endsAt ?? 0, controlMechanic: survivingDispelPlayerControl?.mechanic ?? null } : {}), feedback: nextFeedback( abilityFeedback?.kind ?? (work.progression.levelsGained > 0 ? "level-up" : "success"), abilityFeedback?.message ?? levelMessage, { abilityId: ability.id, targetId: targetId ?? undefined, amount: work.totalDamage || work.totalHealing }, ), }); if (ability.target === "hostile" && targetId && ability.name !== "Hunter's Mark" && ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind))) { get().notifyOwnerHostileAction(PLAYER_AGGRO_ID, targetId); } if (playerRelocationAfterEffects) { useGameStore.getState().relocatePlayer({ footPosition: playerRelocationAfterEffects.position, yaw: playerRelocationAfterEffects.yaw, }); } emitPlayerAbilityResolutionPresentation( ability, now, playerOrigin, targetId, targetPosition, !completingCast, ); if (suddenDoomTrigger) { get().castAbility(suddenDoomTrigger.abilityId, { targetActorId: suddenDoomTrigger.targetId, now, [WOW335_DEATH_KNIGHT_SUDDEN_DOOM_FREE_CAST]: true, } as CastRequest); } const chimeraDamage = ability.name === "Chimera Shot" && targetId && work.totalDamage > 0 ? get().triggerChimeraShot(PLAYER_AGGRO_ID, targetId, ability.id, now) : 0; return { ok: true, abilityId: ability.id, affectedMobIds, damage: work.totalDamage + chimeraDamage, healing: work.totalHealing, levelsGained: work.progression.levelsGained, }; }, castSlot: (index, origin, now) => { const ability = get().abilities[Math.trunc(index)]; if (!ability) { set({ feedback: nextFeedback("error", "That action-bar slot is empty.") }); return emptyCastResult("unknown-ability"); } return get().castAbility(ability.id, origin, now); }, castActionBinding: (layer, control, origin, now) => { const abilityId = get().actionBindings[actionBindingId(layer, control)]; if (!abilityId) { set({ feedback: nextFeedback("error", "That controller binding is empty.") }); return emptyCastResult("unknown-ability"); } const ability = abilityById(abilityId); if (ability && targetingRulesForAbility(ability).shape === "ground") { const pending = get().pendingGroundTarget; if (pending?.abilityId !== abilityId) { get().beginGroundTarget(abilityId); return { ...emptyCastResult(), ok: true, abilityId }; } return get().confirmGroundTarget(undefined, now); } return get().castAbility(abilityId, origin, now); }, damageMob: (id, amount, now = combatNow(), source, school = "physical", rollContext, autoAttackHand) => { const state = get(); const threatSource = withCurrentCoaThreatFactor(state, source ?? playerThreatSource(state), now); const progression = progressionFromState(state); const work: MutableCastWork = { classId: state.classId, statRuleset: state.statRuleset, gender: state.gender, abilities: state.abilities, mobs: { ...state.mobs }, effects: [...state.effects], auras: [...state.auras], summons: [...state.summons], areaEffects: [...state.areaEffects], combatEvents: [...state.combatEvents], actors: { ...state.actors }, cooldowns: { ...state.cooldowns }, health: state.health, maxHealth: state.maxHealth, shield: state.shield, shieldExpiresAt: state.shieldExpiresAt, resource: state.resource, maxResource: state.maxResource, resourcePools: { ...state.resourcePools }, maxResourcePools: { ...state.maxResourcePools }, lastResourceSpentAt: { ...state.lastResourceSpentAt }, comboPoints: state.comboPoints, comboTargetId: state.comboTargetId, deathKnightRunes: state.deathKnightRunes, progression, totalDamage: 0, totalHealing: 0, combatResults: [], talentRanks: state.talentRanks, talentModifiers: state.talentModifiers, gearStats: state.gearStats, raceId: state.raceId, secondaryClassId: state.secondaryClassId, }; const result = rollContext ? resolveAndDamageMobInWork( state.classId, work, id, amount, now, threatSource, rollContext.attackKind, school, rollContext.seed, rollContext.abilityId ? abilityById(rollContext.abilityId) ?? undefined : undefined, rollContext.periodic, rollContext.rules, rollContext.criticalChanceBonusPercent, rollContext.weaponSpeedMs, rollContext.dbcSpellId, ) : damageMobInWork( state.classId, work, id, amount, now, threatSource, school, state.gearStats.spellPenetration, undefined, undefined, false, undefined, autoAttackHand, ); set({ mobs: work.mobs, effects: work.effects, auras: work.auras, summons: work.summons, combatEvents: work.combatEvents, actors: work.actors, cooldowns: work.cooldowns, level: progression.level, xp: progression.xp, xpToNext: progression.xpToNext, health: progression.health, maxHealth: progression.maxHealth, resource: clamp(work.resource, 0, work.maxResource), ...(state.classId === "death-knight" || state.classId === "druid" || (state.classId === "rom-champion" && work.resourcePools.rage !== state.resourcePools.rage) || work.resourcePools.psi !== state.resourcePools.psi ? { resourcePools: { ...work.resourcePools } } : {}), maxResource: work.maxResource, talentModifiers: work.talentModifiers, lastCombatResult: result.combatResult ?? state.lastCombatResult, selectedTargetId: work.mobs[id]?.dead && state.selectedTargetId === id ? null : state.selectedTargetId, autoAttackTargetId: work.mobs[id]?.dead && state.autoAttackTargetId === id ? null : state.autoAttackTargetId, nextAutoAttackAt: work.mobs[id]?.dead && state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt, nextOffhandAutoAttackAt: work.mobs[id]?.dead && state.autoAttackTargetId === id ? 0 : state.nextOffhandAutoAttackAt, autoShotTargetId: work.mobs[id]?.dead && state.autoShotTargetId === id ? null : state.autoShotTargetId, nextAutoShotAt: work.mobs[id]?.dead && state.autoShotTargetId === id ? 0 : state.nextAutoShotAt, comboPoints: work.mobs[id]?.dead && state.selectedTargetId === id ? 0 : state.classId === "druid" ? work.comboPoints : state.comboPoints, comboTargetId: work.mobs[id]?.dead && state.comboTargetId === id ? null : state.classId === "druid" ? work.comboTargetId : state.comboTargetId, feedback: result.levelsGained > 0 ? nextFeedback("level-up", levelUpMessage(state.classId, state.level, progression.level)) : result.combatResult ? nextFeedback("damage", `${state.mobs[id]?.name ?? "Enemy"} ${combatResultDescription(result.combatResult)}.`, { targetId: id, amount: result.amount, abilityId: threatSource.abilityId, }) : nextFeedback("damage", result.killed ? `${state.mobs[id]?.name ?? "Enemy"} defeated.` : `${result.amount} damage.`, { targetId: id, amount: result.amount, abilityId: threatSource.abilityId, }), }); return result; }, deactivateRomScoutBloodArrow: (ownerId, now = combatNow()) => { const state = get(); const playerOwner = ownerId === PLAYER_AURA_ENTITY_ID; const member = playerOwner ? null : usePartyStore.getState().members.find((candidate) => candidate.id === ownerId) ?? null; const ownerBuild = playerOwner ? state : member; if (!ownerBuild || !isRomBloodArrowOwner(ownerBuild) || !hasOwnedRomScoutBloodArrow(state.auras, ownerId, now)) return false; if ((playerOwner && state.health <= 0) || (!playerOwner && (!member || member.health <= 0))) return false; const readyAt = now + ROM_SCOUT_BLOOD_ARROW_DEACTIVATION_COOLDOWN_MS; const auras = state.auras.filter((aura) => !(aura.sourceId === ownerId && aura.targetId === ownerId && aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG))); const actors = state.actors[ownerId] ? { ...state.actors, [ownerId]: { ...state.actors[ownerId], cooldowns: { ...state.actors[ownerId].cooldowns, [ROM_SCOUT_BLOOD_ARROW_ABILITY_ID]: readyAt }, } } : state.actors; if (playerOwner) { set({ auras, actors, cooldowns: { ...state.cooldowns, [ROM_SCOUT_BLOOD_ARROW_ABILITY_ID]: readyAt }, }); return true; } usePartyStore.setState((party) => ({ members: party.members.map((candidate) => candidate.id === ownerId ? { ...candidate, cooldowns: { ...candidate.cooldowns, [ROM_SCOUT_BLOOD_ARROW_ABILITY_ID]: readyAt } } : candidate), })); set({ auras, actors }); return true; }, consumeActorAuraTag: (actorId, tag) => { set((state) => ({ auras: state.auras.filter((aura) => !(aura.targetId === actorId && aura.definition.utilityTags?.includes(tag))), })); }, consumeActorEnrage: (actorId, now = combatNow()) => { const state = get(); if (!state.auras.some((aura) => consumableEnrage(aura, actorId, now))) return false; set({ auras: state.auras.filter((aura) => !consumableEnrage(aura, actorId, now)) }); return true; }, triggerChimeraShot: (sourceActorId, targetId, abilityId, now = combatNow()) => { const state = get(); if (!state.mobs[targetId] || state.mobs[targetId].dead) return 0; const stings = ["Serpent Sting", "Viper Sting", "Scorpid Sting"]; const stingEffect = state.effects.find((effect) => effect.kind === "dot" && effect.sourceActorId === sourceActorId && effect.targetId === targetId && abilityById(effect.sourceAbilityId)?.name === "Serpent Sting"); const stingAura = state.auras.find((aura) => aura.sourceId === sourceActorId && aura.targetId === targetId && stings.some((name) => aura.definition.name.startsWith(name)) && (aura.expiresAt === null || aura.expiresAt > now)); if (!stingAura && !stingEffect) return 0; const stingName = stingAura ? stings.find((name) => stingAura.definition.name.startsWith(name))! : "Serpent Sting"; const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const stingAbility = stingEffect ? abilityById(stingEffect.sourceAbilityId) : abilitiesForClass(sourceMember?.classId ?? state.classId).find((candidate) => candidate.name === stingName) ?? null; const activeSting = stingAbility ? abilityAtLevel(stingAbility, sourceMember?.level ?? state.level) : null; const dot = activeSting?.effects.find((effect) => effect.kind === "dot"); set({ auras: state.auras.map((aura) => aura === stingAura ? { ...aura, expiresAt: now + (aura.definition.durationMs ?? 15_000) } : aura), effects: stingEffect && dot ? state.effects.map((effect) => effect === stingEffect ? { ...effect, remainingTicks: dot.ticks, nextTickAt: now + dot.intervalMs } : effect) : state.effects, }); if (stingName === "Serpent Sting" && stingEffect && dot) { const tick = stingEffect.fixedAmount ?? equipmentAdjustedCombatAmount( stingEffect, sourceMember?.level ?? state.level, sourceMember?.gearStats ?? state.gearStats, sourceMember?.classId ?? state.classId, activeSting ?? stingAbility!, 0, undefined, sourceMember?.raceId ?? state.raceId, sourceMember?.gender ?? state.gender, sourceMember?.statRuleset ?? state.statRuleset, sourceMember?.secondaryClassId ?? state.secondaryClassId, ); return get().damageMob(targetId, Math.max(1, Math.round(tick * dot.ticks * 0.4)), now, threatSourceForActor(get(), sourceActorId, abilityId), "nature").amount; } if (stingName === "Viper Sting") { const drain = activeSting?.effects.find((effect) => effect.kind === "resource-drain"); if (drain) { const current = get(); const actor = current.actors[sourceActorId]; const maximum = sourceActorId === PLAYER_AURA_ENTITY_ID ? current.maxResourcePools.mana ?? current.maxResource : actor?.resources.mana?.maximum ?? 0; const amount = drain.percentage ? maximum * drain.amount / 100 : drain.amount; if (sourceActorId === PLAYER_AURA_ENTITY_ID) { const before = current.resourcePools.mana ?? 0; const restored = Math.min(Math.max(0, amount * 0.6), Math.max(0, maximum - before)); if (restored > 0) { set({ resourcePools: { ...current.resourcePools, mana: before + restored }, ...(resourceProfileForClass(current.classId).type === "mana" ? { resource: before + restored } : {}), actors: actor ? { ...current.actors, [sourceActorId]: { ...actor, resources: { ...actor.resources, mana: { ...(actor.resources.mana ?? normalizeActorResourcePool("mana", before, maximum)), current: before + restored } }, } } : current.actors, }); } } else get().restoreActorResource(sourceActorId, "mana", Math.max(0, amount * 0.6), false, sourceActorId, abilityId, now); } } else if (stingName === "Scorpid Sting" && !get().auras.some((aura) => aura.targetId === sourceActorId && aura.definition.utilityTags?.includes("chimera-scorpid-cooldown") && (aura.expiresAt === null || aura.expiresAt > now))) { get().controlMob(targetId, abilityId, "disarm", 10_000, now); const ability = abilityById(abilityId); if (ability) get().applyActorAura(sourceActorId, sourceActorId, ability, { kind: "apply-aura", recipient: "caster", aura: { id: `wow335:chimera-scorpid-cooldown:${sourceActorId}`, name: "Chimera Shot", disposition: "buff", durationMs: 60_000, maxStacks: 1, stackBehavior: "refresh", utilityTags: ["chimera-scorpid-cooldown"], }, sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0, }, now); } return 0; }, triggerRogueWeaponPoisons: ( sourceActorId, targetId, sourceAbilityId, hitCount = 1, now = combatNow(), ) => { const state = get(); const progression = progressionFromState(state); const work: MutableCastWork = { classId: state.classId, statRuleset: state.statRuleset, gender: state.gender, abilities: state.abilities, mobs: { ...state.mobs }, effects: [...state.effects], auras: [...state.auras], summons: [...state.summons], areaEffects: [...state.areaEffects], combatEvents: [...state.combatEvents], actors: { ...state.actors }, cooldowns: { ...state.cooldowns }, health: state.health, maxHealth: state.maxHealth, shield: state.shield, shieldExpiresAt: state.shieldExpiresAt, resource: state.resource, maxResource: state.maxResource, resourcePools: { ...state.resourcePools }, maxResourcePools: { ...state.maxResourcePools }, lastResourceSpentAt: { ...state.lastResourceSpentAt }, comboPoints: state.comboPoints, comboTargetId: state.comboTargetId, deathKnightRunes: state.deathKnightRunes, progression, totalDamage: 0, totalHealing: 0, combatResults: [], talentRanks: state.talentRanks, talentModifiers: state.talentModifiers, gearStats: state.gearStats, raceId: state.raceId, secondaryClassId: state.secondaryClassId, }; const result = applyRogueWeaponPoisonsInWork( work, sourceActorId, targetId, sourceAbilityId, hitCount, now, ); set({ mobs: work.mobs, effects: work.effects, auras: work.auras, combatEvents: work.combatEvents, actors: work.actors, cooldowns: work.cooldowns, level: progression.level, xp: progression.xp, xpToNext: progression.xpToNext, health: progression.health, maxHealth: progression.maxHealth, resource: clamp(work.resource, 0, work.maxResource), maxResource: work.maxResource, selectedTargetId: work.mobs[targetId]?.dead && state.selectedTargetId === targetId ? null : state.selectedTargetId, autoAttackTargetId: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId ? null : state.autoAttackTargetId, nextAutoAttackAt: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId ? 0 : state.nextAutoAttackAt, nextOffhandAutoAttackAt: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId ? 0 : state.nextOffhandAutoAttackAt, autoShotTargetId: work.mobs[targetId]?.dead && state.autoShotTargetId === targetId ? null : state.autoShotTargetId, nextAutoShotAt: work.mobs[targetId]?.dead && state.autoShotTargetId === targetId ? 0 : state.nextAutoShotAt, }); return result; }, consumeRogueDeadlyPoison: ( sourceActorId, targetId, maximumDoses, now = combatNow(), ) => { const state = get(); const work: MutableCastWork = { classId: state.classId, statRuleset: state.statRuleset, gender: state.gender, abilities: state.abilities, mobs: { ...state.mobs }, effects: [...state.effects], auras: [...state.auras], summons: [...state.summons], areaEffects: [...state.areaEffects], combatEvents: [...state.combatEvents], actors: { ...state.actors }, cooldowns: { ...state.cooldowns }, health: state.health, maxHealth: state.maxHealth, shield: state.shield, shieldExpiresAt: state.shieldExpiresAt, resource: state.resource, maxResource: state.maxResource, resourcePools: { ...state.resourcePools }, maxResourcePools: { ...state.maxResourcePools }, lastResourceSpentAt: { ...state.lastResourceSpentAt }, comboPoints: state.comboPoints, comboTargetId: state.comboTargetId, deathKnightRunes: state.deathKnightRunes, progression: progressionFromState(state), totalDamage: 0, totalHealing: 0, combatResults: [], talentRanks: state.talentRanks, talentModifiers: state.talentModifiers, gearStats: state.gearStats, raceId: state.raceId, secondaryClassId: state.secondaryClassId, }; const consumed = consumeRogueDeadlyPoisonInWork( work, sourceActorId, targetId, maximumDoses, now, ); if (consumed > 0) set({ auras: work.auras, effects: work.effects }); return consumed; }, addHealingThreat: (source, effectiveHealing) => set((state) => { const threatSource = withCurrentCoaThreatFactor(state, source, combatNow()); const engagedIds = Object.values(state.mobs) .filter((mob) => mob.engaged && !mob.dead) .map((mob) => mob.id); const perMob = threatFromHealing(effectiveHealing, engagedIds.length) * (threatSource.threatScale ?? 1) * resolveAuraStatValue(1, aurasForEntity(state.auras, threatSource.actorId), "threat"); if (perMob <= 0) return state; const mobs = { ...state.mobs }; const threatActorId = activeTricksThreatRecipient(state.auras, threatSource.actorId, combatNow()) ?? threatSource.actorId; for (const id of engagedIds) { const mob = mobs[id]; mobs[id] = { ...mob, threatByActor: addActorThreat(mob.threatByActor, threatActorId, perMob), }; } return { mobs }; }), applyActorThreatEffect: (actorId, effect, redirectTargetActorId = null, now = combatNow()) => set((state) => { const mobs: Record = { ...state.mobs }; let changed = false; for (const [id, mob] of Object.entries(mobs)) { if (!mob.engaged || mob.dead) continue; const currentThreat = mob.threatByActor[actorId] ?? 0; const nextThreat = effect.mode === "redirect" ? 0 : effect.mode === "percent" ? currentThreat * Math.max(0, 1 + effect.amount / 100) : Math.max(0, currentThreat + effect.amount); const threatByActor = { ...mob.threatByActor, [actorId]: nextThreat, ...(effect.mode === "redirect" && redirectTargetActorId ? { [redirectTargetActorId]: (mob.threatByActor[redirectTargetActorId] ?? 0) + currentThreat } : {}), }; const resolution = resolveAggroTarget( threatByActor, eligibleAggroActorIds(state, mob), mob.targetActorId, mob.forcedTarget, now, ); mobs[id] = { ...mob, threatByActor, targetActorId: resolution.targetActorId, forcedTarget: resolution.forcedTarget, }; changed = true; } return changed ? { mobs } : state; }), tauntMob: (id, source, durationMs, now = combatNow()) => { const target = get().mobs[id]; if (!target || target.dead || durationMs <= 0 || (target.navigationEvadeUntil ?? 0) > now) return false; const previous = target.tauntDiminishingReturns; const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0; const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1; const effectiveDurationMs = Math.round(durationMs * multiplier); const nextDiminishing = target.boss ? { applications: Math.min(3, applications + 1), resetAt: now + 15_000 } : target.tauntDiminishingReturns; set((state) => { const mobs: Record = { ...state.mobs }; if (effectiveDurationMs <= 0) { mobs[id] = { ...mobs[id], tauntDiminishingReturns: nextDiminishing }; return { mobs }; } const groupId = mobGroupId(id); const groupWasEngaged = Object.entries(mobs).some(([mobId, mob]) => ( mobGroupId(mobId) === groupId && mob.engaged && !mob.dead )); for (const [mobId, mob] of Object.entries(mobs)) { if (mob.dead || mobGroupId(mobId) !== groupId) continue; if ((mob.navigationEvadeUntil ?? 0) > now) continue; const seeded = groupWasEngaged ? mob.threatByActor : seedActorThreat(mob.threatByActor, source.actorId); mobs[mobId] = { ...mob, ...(mobId === id ? { tauntDiminishingReturns: nextDiminishing } : {}), threatByActor: mobId === id ? tauntThreat(seeded, source.actorId) : seeded, targetActorId: mobId === id ? source.actorId : (mob.targetActorId ?? (groupWasEngaged ? null : source.actorId)), forcedTarget: mobId === id ? { actorId: source.actorId, endsAt: now + Math.max(1, effectiveDurationMs) } : mob.forcedTarget, engaged: true, combatPhase: "chasing", homePosition: groupWasEngaged ? (mob.homePosition ?? getMobPosition(mobId)) : (getMobPosition(mobId) ?? mob.homePosition), abilityReadyAt: Object.keys(mob.abilityReadyAt).length ? mob.abilityReadyAt : initialEnemyAbilityReadyAt(mob, now), nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450, }; } return { mobs }; }); return effectiveDurationMs > 0; }, resolveMobUtilitySpellHit: (id, sourceActorId, ability, now = combatNow()) => { if (!enemyUtilitySpellBlockAbilityContract(ability)) return null; let result: CombatResult | null = null; set((state) => { const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); const base = member ? deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId ?? null, member.gender, member.statRuleset ?? "wow") : sourceActorId === PLAYER_AGGRO_ID ? playerCharacterStats(state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, state.talentModifiers, state.gender, state.statRuleset) : null; if (!base) return state; const stats = applyAuraStats(base, effectiveActorAuras(state.auras, sourceActorId, now, "main-hand")); result = resolveNpcUtilityBlockResult(state, id, sourceActorId, stats, ability, now); return result ? { lastCombatResult: result, combatEvents: recordCombatEvent(state.combatEvents, utilitySpellHitEvent(result, id, sourceActorId, ability, now)) } : state; }); return result; }, interruptMob: (id, sourceAbilityId, lockoutMs, now = combatNow()) => { let interrupted = false; set((state) => { const mob = state.mobs[id]; if (!mob?.activeCast?.interruptible) return state; const next = applyStatus(mob, { kind: "status", status: "interrupt", durationMs: Math.max(1, lockoutMs), }, sourceAbilityId, now); interrupted = next.activeCast === null; return interrupted ? { mobs: { ...state.mobs, [id]: next } } : state; }); return interrupted; }, controlMob: (id, sourceAbilityId, status, durationMs, now = combatNow(), breakOnDamage = false, sourceActorId, magnitude) => { let controlled = false; set((state) => { const mob = state.mobs[id]; if (!mob || mob.dead) return state; const exclusive = romSingleTargetControl(abilityById(sourceAbilityId)); const next = applyStatus(mob, { kind: "status", status, durationMs: Math.max(1, durationMs), breakOnDamage, ...(magnitude === undefined ? {} : { magnitude }), }, sourceAbilityId, now, sourceActorId); controlled = next.statuses.some((entry) => ( entry.sourceAbilityId === sourceAbilityId && entry.kind === status && entry.endsAt > now )); if (!controlled) return state; const mobs = exclusive && sourceActorId ? Object.fromEntries(Object.entries(state.mobs).map(([mobId, candidate]) => [ mobId, mobId === id ? next : { ...candidate, statuses: candidate.statuses.filter((entry) => !( entry.sourceAbilityId === sourceAbilityId && entry.sourceActorId === sourceActorId )) }, ])) : { ...state.mobs, [id]: next }; return { mobs }; }); return controlled; }, dispelActor: (id, categories, maxCount = 1, now = combatNow()) => { let removedCount = 0; let clearedCompanionActor: CombatActorState | null = null; let removedBombs: TimedCombatEffect[] = []; set((state) => { const targeted = state.auras.filter((aura) => aura.targetId === id); const untouched = state.auras.filter((aura) => aura.targetId !== id); const result = dispelCombatAuras(targeted, { relationship: "friendly", categories, maxCount: Math.max(1, Math.trunc(maxCount)), }); removedCount = result.removed.length; if (!removedCount) return state; removedBombs = state.effects.filter((effect) => effect.targetId === id && abilityById(effect.sourceAbilityId)?.name === "Living Bomb" && result.removed.some((aura) => aura.sourceId === effect.sourceActorId && aura.definition.name.startsWith("Living Bomb"))); const effects = state.effects.filter((effect) => !removedBombs.includes(effect)); const enemyPeriodicEffects = state.enemyPeriodicEffects.filter((effect) => effect.targetActorId !== id || !result.removed.some((aura) => aura.definition.id === effect.id)); const auras = [...untouched, ...result.auras].filter((aura) => !removedBombs.some((bomb) => ( aura.targetId === id && aura.sourceId === bomb.sourceActorId && aura.definition.name.startsWith("Living Bomb") ))); if (id === PLAYER_AURA_ENTITY_ID && result.removed.some((aura) => aura.definition.id.startsWith("enemy-control:"))) { const remaining = resolveEnemyTriggeredShoutControl(auras, id, now); return { auras, effects, enemyPeriodicEffects, controlledUntil: remaining?.endsAt ?? 0, controlMechanic: remaining?.mechanic ?? null }; } if (result.removed.some((aura) => aura.definition.id.startsWith("enemy-control:"))) { const member = usePartyStore.getState().members.find((candidate) => candidate.id === id); if (member) { const actor = companionActorFromMember( member, entityAbsorbAmount(state.auras, id), state.actors[id], ); const remaining = resolveEnemyTriggeredShoutControl(auras, id, now); clearedCompanionActor = { ...actor, controlledUntil: remaining?.endsAt ?? 0 }; return { auras, effects, enemyPeriodicEffects, actors: { ...state.actors, [id]: clearedCompanionActor }, }; } } return { auras, effects, enemyPeriodicEffects }; }); if (clearedCompanionActor) { mirrorCompanionActor(clearedCompanionActor); const remaining = resolveEnemyTriggeredShoutControl(get().auras, id, now); usePartyStore.getState().updateMemberCombat(id, { controlMechanic: remaining?.mechanic ?? null }); } for (const bomb of removedBombs) detonateLivingBombFromStore(bomb, now); return removedCount; }, triggerRomPriestHolyCandle: (ownerActorId, healingAbilityId, now = combatNow()) => { const state = get(); const partyMember = ownerActorId === PLAYER_AURA_ENTITY_ID ? null : usePartyStore.getState().members.find((member) => member.id === ownerActorId) ?? null; const isPlayerPriest = ownerActorId === PLAYER_AURA_ENTITY_ID && state.classId === "rom-priest" && state.secondaryClassId === null; const isCompanionPriest = partyMember?.classId === "rom-priest" && !partyMember.secondaryClassId; if (!isPlayerPriest && !isCompanionPriest) return 0; const activeAuras = aurasForEntity(state.auras, ownerActorId, now); const actor = isPlayerPriest ? null : partyMember ? companionActorFromMember(partyMember, entityAbsorbAmount(state.auras, ownerActorId), state.actors[ownerActorId]) : null; const maximumHealth = isPlayerPriest ? state.maxHealth : actor?.maxHealth ?? 0; const currentHealth = isPlayerPriest ? state.health : actor?.health ?? 0; const amount = romPriestHolyCandleSelfDamageAmount( maximumHealth, romPriestHolyCandleIsActive(activeAuras), now, state.romPriestHolyCandleLastChargeAt[ownerActorId] ?? null, ); const applied = Math.min(currentHealth, amount); if (applied <= 0) return 0; const health = currentHealth - applied; const nextActor: CombatActorState = isPlayerPriest ? { ...state.actors[ownerActorId]!, health, alive: health > 0 } : { ...actor!, health, alive: health > 0, cast: health > 0 ? actor!.cast : null }; if (!isPlayerPriest) { mirrorCompanionActor(nextActor); if (health <= 0) usePartyStore.setState((party) => ({ members: party.members.map((member) => member.id === ownerActorId ? { ...member, status: "down", activeCast: null, activeCastTargetId: null } : member), })); } set((current) => ({ ...(isPlayerPriest ? { health } : {}), actors: { ...current.actors, [ownerActorId]: nextActor }, romPriestHolyCandleLastChargeAt: { ...current.romPriestHolyCandleLastChargeAt, [ownerActorId]: now, }, combatEvents: recordCombatEvent(current.combatEvents, { occurredAt: now, kind: "damage", sourceActorId: ownerActorId, targetActorId: ownerActorId, abilityId: healingAbilityId, school: "holy", rawAmount: amount, effectiveAmount: applied, overheal: 0, absorbed: 0, critical: false, }), })); return applied; }, healActor: (id, amount, request) => { const now = request.now ?? combatNow(); const state = get(); const sourceAuras = aurasForEntity(state.auras, request.sourceActorId); const targetAuras = aurasForEntity(state.auras, id); const sourcePartyMember = usePartyStore.getState().members.find((member) => member.id === request.sourceActorId); const sourceClassId = sourcePartyMember?.classId ?? (request.sourceActorId === PLAYER_AURA_ENTITY_ID ? state.classId : null); const nativeSourceRuleset = sourcePartyMember?.statRuleset ?? (request.sourceActorId === PLAYER_AURA_ENTITY_ID ? state.statRuleset : undefined); const nativeAmount = nativeSourceRuleset === "rom" && request.romNativeSource ? nativeHolyHealTargetAmount(amount, { romNativeSource: request.romNativeSource }, nativeHealingTargetStats(id, state, state.level, state.auras, now)) : amount; const sourceTalentRanks = sourcePartyMember?.talentRanks ?? (request.sourceActorId === PLAYER_AURA_ENTITY_ID ? state.talentRanks : {}); const masterShapeshifterHealingModifiers = sourceClassId === "druid" ? wow335DruidMasterShapeshifterModifiersForForm( sourceTalentRanks[WOW335_DRUID_MASTER_SHAPESHIFTER_TALENT_ID] ?? 0, wow335DruidShapeshiftFormForAuras(state.auras, request.sourceActorId, now), ).filter((modifier) => modifier.kind === "healing-percent") : []; const masterShapeshifterAdjustedAmount = request.amountAlreadyModified ? nativeAmount : applyTalentModifierValue(nativeAmount, masterShapeshifterHealingModifiers, "healing-percent"); const modified = request.amountAlreadyModified ? nativeAmount : resolveAuraHealingValue( resolveAuraHealingValue(masterShapeshifterAdjustedAmount, sourceAuras, "done"), targetAuras, "taken", ); const partyMember = usePartyStore.getState().members.find((member) => member.id === id); const summon = state.summons.find((candidate) => candidate.id === id); const actor = partyMember ? companionActorFromMember(partyMember, entityAbsorbAmount(state.auras, id), state.actors[id]) : state.actors[id]; const currentHealth = id === PLAYER_AURA_ENTITY_ID ? state.health : summon?.health ?? actor?.health ?? 0; const maximumHealth = id === PLAYER_AURA_ENTITY_ID ? state.maxHealth : summon?.maxHealth ?? actor?.maxHealth ?? 0; if (currentHealth <= 0 || maximumHealth <= 0) return resolveHealing(0, currentHealth, maximumHealth); const resolution = resolveHealing( modified, currentHealth, maximumHealth, request.criticalChance ?? 0, stableCombatUnit(request.seed ?? `${request.sourceActorId}:${request.abilityId ?? "heal"}:${id}:${now}`), request.criticalMultiplier ?? 1.5, ); let healedCompanion: CombatActorState | null = null; set((current) => { const combatEvents = recordCombatEvent(current.combatEvents, { occurredAt: now, kind: "healing", sourceActorId: request.sourceActorId, targetActorId: id, ...(request.abilityId ? { abilityId: request.abilityId } : {}), ...(request.school ? { school: request.school } : {}), rawAmount: resolution.rawAmount, effectiveAmount: resolution.effectiveAmount, overheal: resolution.overheal, absorbed: 0, critical: resolution.critical, }); if (id === PLAYER_AURA_ENTITY_ID) { const playerActor = current.actors[id]; return { health: current.health + resolution.effectiveAmount, actors: playerActor ? { ...current.actors, [id]: { ...playerActor, health: current.health + resolution.effectiveAmount, alive: true }, } : current.actors, combatEvents, }; } const activeSummon = current.summons.find((candidate) => candidate.id === id); if (activeSummon) { const nextHealth = activeSummon.health + resolution.effectiveAmount; const summonActor = current.actors[id]; return { summons: current.summons.map((candidate) => candidate.id === id ? { ...candidate, health: nextHealth } : candidate), actors: summonActor ? { ...current.actors, [id]: { ...summonActor, health: nextHealth, alive: true }, } : current.actors, combatEvents, }; } if (!partyMember || !actor) return { combatEvents }; healedCompanion = { ...actor, health: actor.health + resolution.effectiveAmount, alive: true }; return { actors: { ...current.actors, [id]: healedCompanion }, combatEvents, }; }); if (healedCompanion) mirrorCompanionActor(healedCompanion); return resolution; }, reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = combatNow()) => { const state = get(); const fraction = clamp(percentMaxHealth, 0.01, 1); if (id === PLAYER_AURA_ENTITY_ID) { const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction)); if (restored <= 0) return 0; const playerActor = state.actors[id]; set({ health: restored, controlledUntil: 0, controlMechanic: null, playerAnimationEvent: nextAnimationEvent("ability-cancel"), actors: playerActor ? { ...state.actors, [id]: { ...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null, } } : state.actors, feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }), combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "resurrection", sourceActorId, targetActorId: id, ...(abilityId ? { abilityId } : {}), rawAmount: restored, effectiveAmount: restored, overheal: 0, absorbed: 0, critical: false, }), }); return restored; } const member = usePartyStore.getState().members.find((candidate) => candidate.id === id); if (!member) { const onlineActor = onlinePlayerRuntimeActor(id); if (!onlineActor || onlineActor.health > 0) return 0; const restored = Math.max(1, Math.round(onlineActor.maxHealth * fraction)); set({ actors: { ...state.actors, [id]: { ...onlineActor, health: restored, alive: true, controlledUntil: 0, cast: null }, }, combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "resurrection", sourceActorId, targetActorId: id, ...(abilityId ? { abilityId } : {}), rawAmount: restored, effectiveAmount: restored, overheal: 0, absorbed: 0, critical: false, }), }); return restored; } const actor = companionActorFromMember(member, entityAbsorbAmount(state.auras, id), state.actors[id]); if (actor.alive || actor.health > 0) return 0; const restored = Math.max(1, Math.round(actor.maxHealth * fraction)); const nextActor: CombatActorState = { ...actor, health: restored, alive: true, controlledUntil: 0, cast: null, }; set({ actors: { ...state.actors, [id]: nextActor }, combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "resurrection", sourceActorId, targetActorId: id, ...(abilityId ? { abilityId } : {}), rawAmount: restored, effectiveAmount: restored, overheal: 0, absorbed: 0, critical: false, }), }); mirrorCompanionActor(nextActor); return restored; }, restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = combatNow()) => { const state = get(); const actor = state.actors[id]; if (!actor) return 0; const pool = actor.resources[resource] ?? normalizeActorResourcePool(resource, 0, defaultResourceMaximum(resource)); const requested = Math.max(0, percentage ? pool.maximum * amount / 100 : amount); const restored = Math.min(requested, pool.maximum - pool.current); if (restored <= 0) return 0; const nextActor: CombatActorState = { ...actor, resources: { ...actor.resources, [resource]: { ...pool, current: pool.current + restored }, }, }; const patch: Partial = { actors: { ...state.actors, [id]: nextActor }, combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "resource", sourceActorId, targetActorId: id, ...(abilityId ? { abilityId } : {}), rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false, }), }; if (id === PLAYER_AURA_ENTITY_ID) { patch.resourcePools = { ...state.resourcePools, [resource]: pool.current + restored }; if (resource === resourceProfileForClass(state.classId).type) patch.resource = pool.current + restored; } set(patch); if (nextActor.kind === "companion") mirrorCompanionActor(nextActor); return restored; }, shieldActor: (id, amount, durationMs, sourceActorId, ability, now = combatNow()) => { const state = get(); const absorb = Math.max(0, Math.round(amount)); if (absorb <= 0 || !state.actors[id]) return 0; const result = applyCombatAura(state.auras, { definition: { id: `shield:${sourceActorId}:${ability.id}`, name: ability.name, description: ability.description, icon: ability.icon, disposition: "buff", durationMs: Math.max(1, durationMs), stackBehavior: "replace", absorbs: [{ id: `${ability.id}:absorb`, amount: absorb }], }, sourceId: sourceActorId, targetId: id, now, }); set({ auras: result.auras, combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "absorb", sourceActorId, targetActorId: id, abilityId: ability.id, school: presentationSchoolForAbility(ability), rawAmount: absorb, effectiveAmount: absorb, overheal: 0, absorbed: absorb, critical: false, }), }); return absorb; }, applyActorAura: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => { const state = get(); if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false; if (ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID && (sourceActorId !== targetActorId || !deterrenceAbilityForActor(state, sourceActorId) || !wow335DeterrenceAbilityContract(ability) || !ability.effects.some((ownEffect, index) => ownEffect.kind === "apply-aura" && ownEffect.aura.id === effect.aura.id && wow335DeterrenceAbilityContract({ ...ability, effects: ability.effects.map((entry, ordinal) => ordinal === index ? effect : entry), })))) return false; const isRomPriestChampionBeacon = effect.aura.utilityTags?.includes( ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG, ) ?? false; if (isRomPriestChampionBeacon && targetActorId !== PLAYER_AURA_ENTITY_ID && !usePartyStore.getState().members.some((member) => member.id === targetActorId)) return false; if (ability.name === "Power Word: Shield" && hasWeakenedSoul(state.auras, targetActorId, now)) return false; const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id; const gmGroup = `gm-party-buff:${family}`; if (effect.aura.exclusiveGroup !== gmGroup && state.auras.some((aura) => ( aura.targetId === targetActorId && aura.definition.exclusiveGroup === gmGroup ))) return true; const durationMs = effect.aura.durationMs; const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const sourceShamanRanks = sourceActorId === PLAYER_AURA_ENTITY_ID ? state.classId === "shaman" ? state.talentRanks : null : sourceMember?.classId === "shaman" ? sourceMember.talentRanks : null; const improvedEarthShieldRank = sourceShamanRanks && ability.name === "Earth Shield" ? shamanTalentRankFromRanks(sourceShamanRanks, "Improved Earth Shield", 3) : 0; const sourceAura = improvedEarthShieldAuraDefinition(effect.aura, ability, improvedEarthShieldRank); const definition = auraDefinitionForAbility(sourceAura, ability, durationMs); const existingAuras = isRomPriestChampionBeacon ? state.auras.filter((aura) => !(aura.sourceId === sourceActorId && aura.definition.utilityTags?.includes(ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG))) : state.auras; const applied = applyCombatAura(existingAuras, { definition, sourceId: sourceActorId, targetId: targetActorId, now, }); const shieldAdjusted = ability.name === "Power Word: Shield" ? { ...applied, auras: applyCombatAura(applied.auras, { definition: WEAKENED_SOUL_AURA, sourceId: sourceActorId, targetId: targetActorId, now, }).auras } : applied; const sourceClassId = sourceActorId === PLAYER_AURA_ENTITY_ID ? state.classId : sourceMember?.classId; const sourceSecondaryClassId = sourceActorId === PLAYER_AURA_ENTITY_ID ? state.secondaryClassId : sourceMember?.secondaryClassId ?? undefined; const startsAtZeroStacks = sourceActorId === targetActorId && isRomMageDruidPair({ classId: sourceClassId, secondaryClassId: sourceSecondaryClassId }) && definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG); const unpurgedResult = startsAtZeroStacks ? { ...shieldAdjusted, aura: { ...shieldAdjusted.aura, stacks: 0 }, auras: shieldAdjusted.auras.map((aura) => aura.instanceId === shieldAdjusted.aura.instanceId ? { ...aura, stacks: 0 } : aura), } : shieldAdjusted; const sourceImmunityGrant = (effect.sourceAuraType === 77 && effect.miscValue === 5) || (effect.sourceAuraType === 147 && effect.miscValue === 1733); const controlPurge = purgeNamedEnemyControlShoutAuras(unpurgedResult.auras, targetActorId, sourceImmunityGrant ? enemyControlImmunityPurgeProofForAbility(ability) : undefined, now); const result = { ...unpurgedResult, auras: completeDeterrenceLinkedAuras(controlPurge.auras, ability, sourceActorId, targetActorId, now) }; const grantedAbsorb = result.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0); let health = state.health; let maxHealth = state.maxHealth; let resource = state.resource; let maxResource = state.maxResource; let resourcePools = { ...state.resourcePools }; let maxResourcePools = { ...state.maxResourcePools }; const actors = { ...state.actors }; let companionActor: CombatActorState | null = null; if (targetActorId === PLAYER_AURA_ENTITY_ID) { const nativeMana = nativeManaPoolMaximum( state.classId, state.level, state.talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, result.auras, targetActorId, ); if (nativeMana !== null) { const resized = resizeManaPool(resourcePools, maxResourcePools, nativeMana, false); resourcePools = resized.pools; maxResourcePools = resized.maximums; if (resourceProfileForClass(state.classId).type === "mana") { resource = resized.pools.mana ?? 0; maxResource = nativeMana; } const actor = actors[targetActorId]; if (actor) { const previous = actor.resources.mana; actors[targetActorId] = Object.freeze({ ...actor, resources: { ...actor.resources, mana: normalizeActorResourcePool( "mana", resized.pools.mana ?? 0, nativeMana, previous?.regenerationPerSecond ?? 0, previous?.lastSpentAt ?? 0, ), }, }); } } const currentEnergyMaximum = maxResourcePools.energy ?? defaultResourceMaximum("energy"); const nextEnergyMaximum = romRogueEnergyMaximumAfterAuraTransition( currentEnergyMaximum, state.auras, result.auras, targetActorId, ); if (nextEnergyMaximum !== currentEnergyMaximum) { resourcePools.energy = Math.min(nextEnergyMaximum, resourcePools.energy ?? 0); maxResourcePools.energy = nextEnergyMaximum; if (resourceProfileForClass(state.classId).type === "energy") { resource = resourcePools.energy; maxResource = nextEnergyMaximum; } const actor = actors[targetActorId]; const pool = actor?.resources.energy; if (actor && pool) actors[targetActorId] = { ...actor, resources: { ...actor.resources, energy: { ...pool, current: resourcePools.energy ?? pool.current, maximum: nextEnergyMaximum }, }, }; } const nextMaxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, state.talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, result.auras, state.gender, state.statRuleset, ); const delta = nextMaxHealth - maxHealth; maxHealth = nextMaxHealth; health = health <= 0 ? 0 : clamp(health + delta, 1, maxHealth); const actor = actors[targetActorId]; if (actor) actors[targetActorId] = { ...actor, health, maxHealth }; } else { const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetActorId); const actor = member ? companionActorFromMember( member, entityAbsorbAmount(state.auras, targetActorId), state.actors[targetActorId], ) : null; if (member && actor) { let nextActor = actor; const nativeMana = nativeManaPoolMaximum( member.classId, member.level, talentModifiersForRanks(member.talentRanks, member.classId), member.gearStats, member.raceId, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", result.auras, targetActorId, ); if (nativeMana !== null) { const manaPool = nextActor.resources.mana; const current = Math.min(nativeMana, manaPool?.current ?? member.resourcePools.mana ?? nativeMana); nextActor = Object.freeze({ ...nextActor, resources: { ...nextActor.resources, mana: normalizeActorResourcePool( "mana", current, nativeMana, manaPool?.regenerationPerSecond ?? 0, manaPool?.lastSpentAt ?? 0, ), }, }); } const energyPool = actor.resources.energy; if (energyPool) { const energyMaximum = romRogueEnergyMaximumAfterAuraTransition( energyPool.maximum, state.auras, result.auras, targetActorId, ); if (energyMaximum !== energyPool.maximum) nextActor = Object.freeze({ ...nextActor, resources: { ...nextActor.resources, energy: { ...energyPool, maximum: energyMaximum, current: Math.min(energyMaximum, energyPool.current), }, }, }); } const base = deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", ); const delta = auraMaximumHealthBonus(base, result.auras, targetActorId) - auraMaximumHealthBonus(base, state.auras, targetActorId); if (delta) { const actorMaxHealth = Math.max(1, nextActor.maxHealth + delta); const actorHealth = nextActor.health <= 0 ? 0 : clamp(nextActor.health + delta, 1, actorMaxHealth); nextActor = Object.freeze({ ...nextActor, health: actorHealth, maxHealth: actorMaxHealth }); } if (nextActor !== actor) companionActor = nextActor; if (companionActor) actors[targetActorId] = companionActor; } } let combatEvents = recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "aura", sourceActorId, targetActorId, abilityId: ability.id, school: presentationSchoolForAbility(ability), rawAmount: 0, effectiveAmount: 1, overheal: 0, absorbed: 0, critical: false, }); if (grantedAbsorb > 0) { combatEvents = recordCombatEvent(combatEvents, { occurredAt: now, kind: "absorb", sourceActorId, targetActorId, abilityId: ability.id, school: presentationSchoolForAbility(ability), rawAmount: grantedAbsorb, effectiveAmount: grantedAbsorb, overheal: 0, absorbed: grantedAbsorb, critical: false, }); } const remainingSourceControl = controlPurge.removed.length ? remainingEnemyControlAfterGrant(result.auras, targetActorId, now) : null; if (controlPurge.removed.length && targetActorId !== PLAYER_AURA_ENTITY_ID && actors[targetActorId]) { companionActor = { ...actors[targetActorId], controlledUntil: remainingSourceControl?.endsAt ?? 0 }; actors[targetActorId] = companionActor; } set({ auras: result.auras, health, maxHealth, resource, maxResource, resourcePools, maxResourcePools, actors, combatEvents, ...(controlPurge.removed.length && targetActorId === PLAYER_AURA_ENTITY_ID ? { controlledUntil: remainingSourceControl?.endsAt ?? 0, controlMechanic: remainingSourceControl?.mechanic ?? null } : {}), }); if (companionActor) mirrorCompanionActor(companionActor); if (controlPurge.removed.length && targetActorId !== PLAYER_AURA_ENTITY_ID) { usePartyStore.getState().updateMemberCombat(targetActorId, { controlledUntil: remainingSourceControl?.endsAt ?? 0, controlMechanic: remainingSourceControl?.mechanic ?? null, }); } return true; }, applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow(), criticalChanceBonusPercent = 0) => { const state = get(); if (!state.actors[sourceActorId]) return false; const selfDrainPercent = effect.kind === "dot" ? romSelfDrainPercent(ability.dbcSpellId) : 0; if (effect.kind === "dot" && selfDrainPercent === 0 && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false; if (selfDrainPercent > 0 && sourceActorId !== targetActorId) return false; if (effect.kind === "hot") { const targetHealth = targetActorId === PLAYER_AURA_ENTITY_ID ? state.health : state.summons.find((summon) => summon.id === targetActorId)?.health ?? state.actors[targetActorId]?.health ?? 0; if (!state.actors[targetActorId] || targetHealth <= 0) return false; } const sourceActor = state.actors[sourceActorId]; const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const sourceClassId = sourceMember?.classId ?? (sourceActorId === PLAYER_AURA_ENTITY_ID ? state.classId : null); const sourceBaseStats = sourceMember ? playerCharacterStats( sourceMember.classId, sourceMember.level, sourceMember.gearStats, sourceMember.raceId, sourceMember.secondaryClassId ?? null, talentModifiersForRanks(sourceMember.talentRanks, sourceMember.classId), sourceMember.gender, sourceMember.statRuleset ?? "wow", ) : sourceActorId === PLAYER_AURA_ENTITY_ID ? playerCharacterStats( state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, state.talentModifiers, state.gender, state.statRuleset, ) : null; const holyBlessingSpellPowerBonus = sourceClassId === "rom-priest" && sourceBaseStats && (sourceMember?.statRuleset ?? state.statRuleset) !== "rom" ? romPriestHolyBlessingStats( sourceBaseStats, aurasForEntity(state.auras, sourceActorId, now), ).spellPowerBonus : 0; const baseAmount = selfDrainPercent > 0 ? Math.max(1, Math.round((sourceMember?.maxHealth ?? sourceActor.maxHealth) * selfDrainPercent / 100)) : sourceMember ? equipmentAdjustedCombatAmount( effect, sourceMember.level, sourceMember.gearStats, sourceMember.classId, ability, holyBlessingSpellPowerBonus, undefined, sourceMember.raceId, sourceMember.gender, sourceMember.statRuleset ?? "wow", sourceMember.secondaryClassId, sourceBaseStats ? auraAdjustedCharacterStats(sourceBaseStats, state.auras, sourceActorId, now).romNative : undefined, undefined, activeEnemyDisarmChannels(state.auras, sourceActorId, now), ) : Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * (sourceActor.level * 4 + holyBlessingSpellPowerBonus))); const masterShapeshifterHealingModifiers = sourceMember?.classId === "druid" ? wow335DruidMasterShapeshifterModifiersForForm( sourceMember.talentRanks[WOW335_DRUID_MASTER_SHAPESHIFTER_TALENT_ID] ?? 0, wow335DruidShapeshiftFormForAuras(state.auras, sourceActorId, now), ).filter((modifier) => modifier.kind === "healing-percent") : []; const amount = sourceMember?.classId === "druid" && effect.kind === "hot" ? talentAdjustedAmount( baseAmount, [ ...talentModifiersForRanks(sourceMember.talentRanks, sourceMember.classId), ...masterShapeshifterHealingModifiers, ], "healing-percent", ability, true, ) : baseAmount; const nativeSnapshot = effect.kind === "hot" && sourceBaseStats ? nativePeriodicHealSnapshot(effect, sourceMember?.level ?? state.level, auraAdjustedCharacterStats(sourceBaseStats, state.auras, sourceActorId, now).romNative, nativeHealingTargetStats(targetActorId, state, state.level, state.auras, now), state.actors[targetActorId]?.level ?? state.level) : undefined; const nativeDot = effect.kind === "dot" && selfDrainPercent === 0 && sourceBaseStats ? nativeOutgoingPeriodicSnapshot(effect, auraAdjustedCharacterStats(sourceBaseStats, state.auras, sourceActorId, now).romNative, state.mobs[targetActorId], state.auras, `${sourceActorId}:${ability.id}:${targetActorId}:${now}:dot-application`, sourceActorId, now) : undefined; if (nativeDot === null || (effect.kind === "dot" && selfDrainPercent === 0 && state.mobs[targetActorId]?.schoolImmunities.includes(presentationSchoolForAbility(ability)))) return false; const next = state.effects.filter((entry) => !( entry.kind === effect.kind && entry.sourceAbilityId === ability.id && entry.targetId === targetActorId )); next.push({ ...timedEffect( effect.kind, ability.id, sourceActorId, targetActorId, { ...effect, coefficient: nativeDot?.tickDamageAtApplication ?? nativeSnapshot?.tickAmount ?? amount }, nativeDot?.ticks ?? nativeSnapshot?.dotTimes ?? effect.ticks, nativeDot ? nativeDot.tickIntervalSeconds * 1_000 : effect.intervalMs, now, selfDrainPercent > 0 ? "rom-self-health-drain" : effect.kind === "dot" ? effect.tag : undefined, effect.kind === "dot" ? presentationSchoolForAbility(ability) : undefined, sourceMember?.gearStats.spellPenetration, criticalChanceBonusPercent, ), fixedAmount: nativeDot?.tickDamageAtApplication ?? nativeSnapshot?.tickAmount ?? amount, ...(nativeDot ? { targetMitigationSnapshotted: true, nativePeriodicDamageSnapshot: nativeDot } : {}) }); set({ effects: next }); return true; }, summonActor: (ownerActorId, ability, effect, position, targetId = null, now = combatNow()) => { const state = get(); const owner = state.actors[ownerActorId]; if (!owner?.alive) return null; summonSequence += 1; const isTotem = /totem/i.test(ability.name); const isHunterPet = ability.classId === "hunter" && ability.effects.some((candidate) => ( candidate.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(candidate.action) )); const hunterPetAction = ability.effects.find((candidate): candidate is Extract => ( candidate.kind === "pet" ))?.action; const ownerMember = isHunterPet ? usePartyStore.getState().members.find((member) => member.id === ownerActorId) : undefined; const hunterPetOwnerBaseStats = ownerMember ? playerCharacterStats( ownerMember.classId, ownerMember.level, ownerMember.gearStats, ownerMember.raceId, ownerMember.secondaryClassId ?? null, talentModifiersForRanks(ownerMember.talentRanks, ownerMember.classId), ownerMember.gender, ownerMember.statRuleset ?? "wow", ) : undefined; const hunterPetOwnerStats = ownerMember && hunterPetOwnerBaseStats ? applyAuraStats(hunterPetOwnerBaseStats, aurasForEntity(state.auras, ownerActorId)) : undefined; const hunterPetProfile = isHunterPet ? wow335HunterPetProfileForLevel(owner.level, "wolf", hunterPetOwnerStats) : undefined; const hunterPetEffectiveStats = hunterPetProfile?.effectiveStats; const existingHunterPet = isHunterPet ? state.summons.find((summon) => summon.ownerActorId === ownerActorId && summon.hunterPet) : undefined; if (existingHunterPet) { if (hunterPetAction === "resurrect" && existingHunterPet.health <= 0) { const revived = { ...existingHunterPet, maxHealth: hunterPetEffectiveStats?.maxHealth ?? existingHunterPet.maxHealth, health: Math.max(1, Math.round((hunterPetEffectiveStats?.maxHealth ?? existingHunterPet.maxHealth) * 0.15)), attackDamage: hunterPetEffectiveStats?.averageAutoAttackDamage ?? existingHunterPet.attackDamage, position, targetId: hunterPetProfile ? null : targetId, nextActionAt: now + 1_000, ...(hunterPetProfile ? initialHunterPetCombatRuntime(hunterPetProfile, now) : {}), }; set({ summons: state.summons.map((summon) => summon.id === revived.id ? revived : summon) }); get().synchronizeActors(); } else if (hunterPetAction === "call" && existingHunterPet.health > 0) { set({ summons: state.summons.map((summon) => summon.id === existingHunterPet.id ? { ...summon, position, targetId: null } : summon) }); get().synchronizeActors(); } return existingHunterPet.id; } const id = `${ownerActorId}:${isTotem ? "totem" : "summon"}-${summonSequence}`; const maxHealth = effect.healingPulse ? 5 : hunterPetEffectiveStats?.maxHealth ?? Math.max(1, Math.round(owner.maxHealth * (isTotem ? 0.1 : 0.3))); const summon: CombatSummon = { id, sourceAbilityId: ability.id, creatureId: effect.creatureId, targetId: hunterPetProfile ? null : targetId, expiresAt: now + effect.durationMs, kind: isTotem ? "totem" : "summon", name: isHunterPet ? "Wolf" : ability.name, ownerActorId, health: maxHealth, maxHealth, position, nextActionAt: now + (effect.healingPulse?.intervalMs ?? 1_000), pulseIntervalMs: effect.healingPulse?.intervalMs ?? (ability.name === "Shadowfiend" ? 1_500 : isTotem ? 2_000 : 2_200), attackDamage: effect.healingPulse || ability.name === "Lightwell" ? 0 : hunterPetEffectiveStats?.averageAutoAttackDamage ?? Math.max(1, Math.round(owner.level * 4 * Math.max(0.15, effect.coefficient))), ...(ability.name === "Lightwell" ? { clickCharges: 10 } : {}), ...(effect.healingPulse ? { healingPulse: effect.healingPulse } : {}), ...initialSummonCombatRuntime(), ...(hunterPetProfile ? initialHunterPetCombatRuntime(hunterPetProfile, now) : {}), ...(isTotem ? { exclusiveGroup: `${ownerActorId}:totem` } : {}), }; const summons = isTotem ? [...state.summons.filter((candidate) => candidate.exclusiveGroup !== summon.exclusiveGroup), summon] : [...state.summons, summon]; set({ summons }); get().synchronizeActors(); return id; }, commandHunterPet: (summonId, command) => { const state = get(); const pet = state.summons.find((summon) => summon.id === summonId && summon.hunterPet && summon.ownerActorId === PLAYER_AGGRO_ID && summon.health > 0); if (!pet) return false; if (command === "attack") { const targetId = state.selectedTargetId; const target = targetId ? state.mobs[targetId] : null; if (!target || target.dead || target.despawned || (target.navigationEvadeUntil ?? 0) > combatNow()) return false; set((current) => ({ summons: current.summons.map((summon) => summon.id === summonId ? { ...summon, targetId, hunterPetCommand: "attack", hunterPetCommandTargetId: targetId, hunterPetIsCommandAttack: true, hunterPetIsCommandFollow: false, hunterPetIsReturning: false, hunterPetIsFollowing: false, } : summon), })); return true; } if (command === "follow") { const isReturning = distanceSquared(state.playerPosition, pet.position) > 9; set((current) => ({ summons: current.summons.map((summon) => summon.id === summonId ? { ...summon, targetId: null, hunterPetCommand: "follow", hunterPetCommandTargetId: null, hunterPetStayPosition: null, hunterPetIsCommandAttack: false, hunterPetIsCommandFollow: true, hunterPetIsReturning: isReturning, hunterPetIsFollowing: !isReturning, hunterPetPendingOwnerAttackTargetId: null, } : summon), })); return true; } set((current) => ({ summons: current.summons.map((summon) => summon.id === summonId ? { ...summon, hunterPetCommand: "stay", hunterPetCommandTargetId: null, hunterPetStayPosition: summon.position, hunterPetIsCommandAttack: false, hunterPetIsCommandFollow: false, hunterPetIsReturning: false, hunterPetIsFollowing: false, } : summon), })); return true; }, setHunterPetReactMode: (summonId, reactMode) => { const state = get(); const pet = state.summons.find((summon) => summon.id === summonId && summon.hunterPet && summon.ownerActorId === PLAYER_AGGRO_ID); if (!pet) return false; set((current) => ({ summons: current.summons.map((summon) => { if (summon.id !== summonId) return summon; const stoppedManualAttack = reactMode === "passive" && summon.hunterPetCommand === "attack"; return { ...summon, hunterPetReactMode: reactMode, ...(reactMode === "passive" ? { targetId: null, hunterPetCommand: stoppedManualAttack ? "follow" as const : summon.hunterPetCommand, hunterPetCommandTargetId: null, hunterPetIsCommandAttack: false, hunterPetPendingOwnerAttackTargetId: null, } : {}), }; }), })); return true; }, setHunterPetFamily: (family) => { const state = get(); if (state.classId !== "hunter" || !isWow335HunterPetFamily(family)) return false; if (family === state.hunterPetFamily) return true; const hasActiveEncounter = Object.values(state.mobs).some((mob) => ( mob.engaged && !mob.dead && !mob.despawned )); if (hasActiveEncounter) return false; const now = combatNow(); const ownerStats = applyAuraStats( playerCharacterStats( state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, state.talentModifiers, state.gender, state.statRuleset, ), aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID), ); const profile = wow335HunterPetProfileForLevel(state.level, family, ownerStats); const maximumHealth = profile.effectiveStats.maxHealth; set((current) => ({ hunterPetFamily: family, auras: current.auras.filter((aura) => !( current.summons.some((summon) => summon.id === aura.targetId && summon.hunterPet && summon.ownerActorId === PLAYER_AGGRO_ID) && aura.definition.utilityTags?.some((tag) => ( tag === WOW335_HUNTER_PET_SHELL_SHIELD_TAG || tag === WOW335_HUNTER_PET_PROWL_TAG )) )), summons: current.summons.map((summon) => summon.hunterPet && summon.ownerActorId === PLAYER_AGGRO_ID ? { ...summon, name: wow335HunterPetFamilyName(family), maxHealth: maximumHealth, health: Math.min(summon.health, maximumHealth), attackDamage: profile.effectiveStats.averageAutoAttackDamage, hunterPet: profile, hunterPetSpecialReadyAt: now, hunterPetRakeReadyAt: profile.rake ? now : undefined, hunterPetProwlReadyAt: profile.prowl ? now : undefined, ...(profile.shellShield && summon.hunterPetShellShieldReadyAt === undefined ? { hunterPetShellShieldReadyAt: now } : {}), } : summon), })); get().synchronizeActors(); return true; }, setHunterPetGrowlAutocast: (summonId, enabled) => { const state = get(); if (!state.summons.some((summon) => summon.id === summonId && summon.hunterPet)) return false; set((current) => ({ summons: current.summons.map((summon) => summon.id === summonId ? { ...summon, hunterPetGrowlAutocastEnabled: enabled } : summon) })); return true; }, notifyOwnerHostileAction: (ownerActorId, targetId) => { const state = get(); const target = state.mobs[targetId]; if (!target || target.dead || target.despawned || (target.navigationEvadeUntil ?? 0) > combatNow()) return; if (!state.summons.some((summon) => summon.hunterPet && summon.ownerActorId === ownerActorId)) return; set((current) => ({ summons: current.summons.map((summon) => summon.hunterPet && summon.ownerActorId === ownerActorId ? { ...summon, hunterPetPendingOwnerAttackTargetId: targetId } : summon), })); }, clickLightwell: (summonId, targetActorId, now = combatNow()) => { const state = get(); const well = state.summons.find((summon) => summon.id === summonId && summon.sourceAbilityId === "wow335-priest-lightwell" && (summon.clickCharges ?? 0) > 0 && summon.expiresAt > now); if (!well) return 0; const target = targetActorId ?? state.selectedFriendlyActorId ?? PLAYER_AURA_ENTITY_ID; const partyMember = usePartyStore.getState().members.some((member) => member.id === target); if (target !== PLAYER_AURA_ENTITY_ID && !partyMember) return 0; const targetActor = target === PLAYER_AURA_ENTITY_ID ? state.actors[target] : state.actors[target]; if (!targetActor?.alive) return 0; const ownerLevel = well.ownerActorId === PLAYER_AGGRO_ID ? state.level : state.actors[well.ownerActorId]?.level ?? state.level; const profile = wow335LightwellRenewProfileForCreature(well.creatureId); if (!profile) return 0; const ability = abilityById(well.sourceAbilityId); if (!ability) return 0; const scaling = { coefficient: profile.spellPowerCoefficientPerTick ?? 0, basePoints: profile.baseHealPerTick, sourceLevel: ownerLevel, maxScalingLevel: ownerLevel, }; const cancellationFraction = wow335LightwellDamageCancellationFraction(ability.description); const ownerMember = usePartyStore.getState().members.find((member) => member.id === well.ownerActorId); const tickAmount = well.ownerActorId === PLAYER_AGGRO_ID ? equipmentAdjustedCombatAmount(scaling, ownerLevel, state.gearStats, state.classId, ability, 0, undefined, state.raceId, state.gender, state.statRuleset, state.secondaryClassId) : ownerMember ? equipmentAdjustedCombatAmount(scaling, ownerLevel, ownerMember.gearStats, ownerMember.classId, ability, 0, undefined, ownerMember.raceId, ownerMember.gender, ownerMember.statRuleset ?? "wow", ownerMember.secondaryClassId) : profile.baseHealPerTick; const amount = tickAmount * profile.ticks; const periodicHeal = { ...timedEffect( "hot", well.sourceAbilityId, well.ownerActorId, target === PLAYER_AURA_ENTITY_ID ? null : target, scaling, profile.ticks, profile.intervalMs, now, ), ...(well.ownerActorId === PLAYER_AGGRO_ID ? {} : { fixedAmount: tickAmount }), ...(cancellationFraction === null ? {} : { lightwellDamageCancellation: { threshold: targetActor.maxHealth * cancellationFraction, accumulatedDamage: 0, }, }), } satisfies TimedCombatEffect; set((current) => ({ effects: [...current.effects, periodicHeal], summons: current.summons.flatMap((summon) => { if (summon.id !== summonId) return [summon]; const clickCharges = Math.max(0, (summon.clickCharges ?? 0) - 1); return clickCharges > 0 ? [{ ...summon, clickCharges }] : []; }), })); return amount; }, createActorAreaEffect: (sourceActorId, ability, position, now = combatNow()) => { const state = get(); const sourceActor = state.actors[sourceActorId]; if (!sourceActor?.alive) return null; const targeting = targetingRulesForAbility(ability); if (targeting.shape !== "ground") return null; const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const sourcePosition = actorPosition(state, sourceActorId) ?? state.playerPosition; const selfPlacedHunterTrapKind = sourceMember?.classId === "hunter" ? ability.name === "Freezing Trap" ? "freezing" : ability.name === "Frost Trap" ? "frost" : ability.name === "Immolation Trap" ? "immolation" : ability.name === "Explosive Trap" ? "explosive" : null : null; if (selfPlacedHunterTrapKind) { const trapDurationMs = ability.effects.find((effect) => effect.kind === "utility")?.durationMs || 30_000; const directDamageScaling = selfPlacedHunterTrapKind === "explosive" ? hunterExplosiveTrapDirectScaling(ability.dbcSpellId) ?? undefined : undefined; const replacedTraps = state.areaEffects.filter((area) => ( area.sourceActorId === sourceActorId && area.hunterTrap )); const removedZoneSlowIds = new Set(replacedTraps .filter((area) => area.hunterTrap?.kind === "frost-zone") .map((area) => `hunter-trap-zone:${area.id}`)); const mobs = removedZoneSlowIds.size === 0 ? state.mobs : Object.fromEntries( Object.entries(state.mobs).map(([id, mob]) => [id, { ...mob, statuses: mob.statuses.filter((status) => !removedZoneSlowIds.has(status.sourceAbilityId)), }]), ); areaEffectSequence += 1; const id = `${sourceActorId}:area-${areaEffectSequence}`; const area: CombatAreaEffect = { id, sourceActorId, sourceAbilityId: ability.id, position: sourcePosition, radius: 2, createdAt: now, expiresAt: now + trapDurationMs, tickIntervalMs: 0, nextTickAt: Number.MAX_SAFE_INTEGER, hunterTrap: { kind: selfPlacedHunterTrapKind, triggerRadius: 2, ...(selfPlacedHunterTrapKind === "freezing" ? { freezeDurationMs: freezingTrapDurationMs(ability.dbcSpellId) } : {}), ...(directDamageScaling ? { directDamageScaling } : {}), }, }; set({ areaEffects: [ ...state.areaEffects.filter((area) => !( area.sourceActorId === sourceActorId && area.hunterTrap )), area, ], mobs, }); return id; } const baseGroundRange = targeting.groundRange ?? ability.range.max; const flameThrowingBonusYards = sourceMember?.classId === "mage" && ability.target === "hostile" && ability.effects.some((effect) => ( effect.kind === "damage" || effect.kind === "finisher-damage" || effect.kind === "dot" || effect.kind === "execute" || effect.kind === "resource-drain" )) ? wow335MageFlameThrowingProfile( sourceMember.talentRanks[WOW335_MAGE_FLAME_THROWING_TALENT_ID] ?? 0, ).damagingSpellRangeBonusYards : 0; const projected = projectCombatGroundPoint( position, baseGroundRange + flameThrowingBonusYards, sourcePosition, ); if (!projected || !combatHasLineOfSight(sourcePosition, projected)) return null; if (sourceMember?.classId === "hunter" && ability.name === "Freezing Arrow") { areaEffectSequence += 1; const id = `${sourceActorId}:area-${areaEffectSequence}`; const area: CombatAreaEffect = { id, sourceActorId, sourceAbilityId: ability.id, position: projected, radius: 2, createdAt: now, expiresAt: now + 30_000, tickIntervalMs: 0, nextTickAt: Number.MAX_SAFE_INTEGER, hunterTrap: { kind: "freezing", triggerRadius: 2, freezeDurationMs: freezingTrapDurationMs(ability.dbcSpellId), }, }; set({ areaEffects: [...state.areaEffects, area] }); return id; } const periodic = ability.effects.filter((effect): effect is Extract => ( effect.kind === "dot" || effect.kind === "hot" )); if (!periodic.length) return null; const power = sourceMember ? sourceMember.level * 4 + sourceMember.gearStats.spellPower + sourceMember.gearStats.attackPower * 0.35 + sourceMember.gearStats.rangedAttackPower * 0.35 : sourceActor.level * 4; const scaled = (effect: Extract) => Math.max( 1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * Math.max(8, power)), ); areaEffectSequence += 1; const id = `${sourceActorId}:area-${areaEffectSequence}`; const interval = Math.max(100, Math.min(...periodic.map((effect) => effect.intervalMs))); const duration = Math.max(...periodic.map((effect) => effect.intervalMs * effect.ticks)); const area: CombatAreaEffect = { id, sourceActorId, sourceAbilityId: ability.id, position: projected, radius: targeting.radius ?? ability.radius ?? 8, createdAt: now, expiresAt: now + duration, tickIntervalMs: interval, nextTickAt: now + interval, fixedDamagePerTick: periodic.filter((effect) => effect.kind === "dot").reduce((sum, effect) => sum + scaled(effect), 0), fixedHealingPerTick: periodic.filter((effect) => effect.kind === "hot").reduce((sum, effect) => sum + scaled(effect), 0), }; set({ areaEffects: [...state.areaEffects, area] }); return id; }, damagePlayer: (amount, school = "physical", attackerLevel, now = combatNow()) => { const state = get(); if (state.classId === "rom-warden" && activeWardenNatureCrystal(state.summons, now)) return 0; const playerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID, now); const mitigationGear = state.classId === "guardian" ? { ...state.gearStats, armor: resolveAuraStatValue(state.gearStats.armor, playerAuras, "armor") } : state.gearStats; const mitigated = damageAfterEquipmentMitigation( talentAdjustedIncomingDamage(amount, state.talentModifiers, school), mitigationGear, state.level, school, attackerLevel ?? state.level, ); const priestMitigated = priestTestOfFaithIncomingDamage( mitigated, state.classId, state.talentRanks, state.activeCast, school, now, ); const defensiveStanceActive = state.classId === "warrior" && wow335WarriorHasDefensiveStance(playerAuras, PLAYER_AURA_ENTITY_ID, now); const damageTransferPet = activeWardenDamageTransferPet( state.summons, PLAYER_AGGRO_ID, now, state.playerPosition, ); const damageTransferActive = Boolean(damageTransferPet && playerAuras.some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(ROM_WARDEN_DAMAGE_TRANSFER_TAG) ))); const damageTransferAuras = playerAuras.filter((aura) => ( !aura.definition.utilityTags?.includes(ROM_WARDEN_DAMAGE_TRANSFER_TAG) )); const damageTransferContext = { direction: "taken" as const, school, attackKind: "spell" as const }; const damageBeforeTransferBase = damageTransferActive ? resolveAuraDamageValue(priestMitigated, damageTransferAuras, damageTransferContext) : 0; const damageBeforeTransfer = damageTransferActive ? wow335WarriorImprovedDefensiveStanceAdditiveIncomingDamage( priestMitigated, damageBeforeTransferBase, state.talentRanks[WOW335_WARRIOR_IMPROVED_DEFENSIVE_STANCE_TALENT_ID] ?? 0, defensiveStanceActive, ) : 0; const incomingBase = resolveAuraDamageValue( priestMitigated, damageTransferActive ? playerAuras : damageTransferAuras, damageTransferContext, ); const incoming = wow335WarriorImprovedDefensiveStanceAdditiveIncomingDamage( priestMitigated, incomingBase, state.talentRanks[WOW335_WARRIOR_IMPROVED_DEFENSIVE_STANCE_TALENT_ID] ?? 0, defensiveStanceActive, ); const soulLinkDamage = resolveWarlockSoulLinkDamage( incoming, state.classId, state.talentRanks, state.auras, state.summons, PLAYER_AURA_ENTITY_ID, state.playerPosition, now, ); const damageTransferredToPet = damageTransferActive ? Math.round(damageBeforeTransfer * ROM_WARDEN_DAMAGE_TRANSFER_FRACTION) : 0; const namedAbsorb = absorbAuraDamage(state.auras, { targetId: PLAYER_AURA_ENTITY_ID, amount: soulLinkDamage.casterDamage, school, now, countsAsAttack: true, }); const absorbed = Math.min(state.shield, namedAbsorb.remainingDamage); const healthDamage = Math.min(state.health, namedAbsorb.remainingDamage - absorbed); const petAbsorb = damageTransferPet && damageTransferredToPet > 0 ? absorbAuraDamage(namedAbsorb.auras, { targetId: damageTransferPet.id, amount: damageTransferredToPet, school, now, countsAsAttack: true, }) : null; const petHealthDamage = damageTransferPet && petAbsorb ? Math.min(damageTransferPet.health, Math.max(0, Math.round(petAbsorb.remainingDamage))) : 0; const wardenUpdatedSummons = damageTransferPet && petAbsorb && petHealthDamage > 0 ? state.summons.flatMap((summon) => summon.id !== damageTransferPet.id ? [summon] : summon.health - petHealthDamage > 0 ? [{ ...summon, health: summon.health - petHealthDamage }] : []) : state.summons; const nextSummons = soulLinkDamage.demon && soulLinkDamage.demonDamage > 0 ? wardenUpdatedSummons.flatMap((summon) => summon.id !== soulLinkDamage.demon!.id ? [summon] : summon.health - soulLinkDamage.demonDamage > 0 ? [{ ...summon, health: summon.health - soulLinkDamage.demonDamage }] : []) : wardenUpdatedSummons; const nextActors = { ...state.actors }; if (soulLinkDamage.demon && soulLinkDamage.demonDamage > 0) { const updatedDemon = nextSummons.find((summon) => summon.id === soulLinkDamage.demon!.id); if (updatedDemon) nextActors[updatedDemon.id] = combatActorFromSummon(updatedDemon, state.level, state.auras); else delete nextActors[soulLinkDamage.demon.id]; } const remodeledBody = namedAbsorb.auras.find((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("rom-remodeled-body") )); const preventedDeath = Boolean(remodeledBody && healthDamage >= state.health); let health = preventedDeath ? state.maxHealth : state.health - healthDamage; const postAbsorbAuras = petAbsorb?.auras ?? namedAbsorb.auras; const aurasAfterCheatDeath = preventedDeath ? postAbsorbAuras.filter((aura) => aura.instanceId !== remodeledBody!.instanceId) : postAbsorbAuras; const rage = gainRomRage( state.classId, state.secondaryClassId, healthDamage > 0 && ( hasRomWarriorRagePool(state.classId, state.secondaryClassId) || (!hasRomRagePool(state.classId, state.secondaryClassId) && state.maxResourcePools.rage !== undefined) ) ? RAGE_PER_HIT_TAKEN : 0, state.resource, state.maxResource, { ...state.resourcePools }, { ...state.maxResourcePools }, ); const deathKnightResource = grantDeathKnightAntiMagicShellRunicPower( state.classId, rage.resource, rage.resourcePools, state.maxResource, state.maxResourcePools, deathKnightAntiMagicShellRunicPowerGain( state.auras, namedAbsorb.consumptions, PLAYER_AURA_ENTITY_ID, school, now, ), ); const procResult = evaluateAuraProcs(aurasAfterCheatDeath, { trigger: "damage-taken", subjectId: PLAYER_AURA_ENTITY_ID, amount: soulLinkDamage.casterDamage, school, }, now, ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:damage-taken:${state.health}`)); let nextAuras = spendDeathKnightBoneShieldCharge(procResult.auras, procResult.triggered); nextAuras = maybeApplyWarlockBacklash( nextAuras, state.classId, state.talentRanks, school === "physical" ? "melee" : "spell", school, incoming > 0, `${PLAYER_AURA_ENTITY_ID}:external:${school}:${now}:${state.health}`, now, ); const revivalStateLearned = state.abilities.some((ability) => ( ability.source === "runewaker" && ability.dbcSpellId === 499544 && isAbilityUnlocked(ability, state.level) )); if (romWardenRevivalShouldTriggerOnIncomingHit({ classId: state.classId, secondaryClassId: state.secondaryClassId, stateActive: revivalStateLearned && romWardenRevivalStateIsActive( state.auras, PLAYER_AURA_ENTITY_ID, now, ), // damagePlayer has no attack-result payload. Its positive post-absorb // health loss is the existing normal-hit adapter boundary. normal: true, healthDamage, healthAfter: health, })) { nextAuras = applyRomWardenRevivalOnHit(nextAuras, PLAYER_AURA_ENTITY_ID, now); } let livingSeedRequested = 0; let livingSeedHealing = 0; if (incoming > 0) { const livingSeed = consumeDruidLivingSeed(nextAuras, PLAYER_AURA_ENTITY_ID, now); nextAuras = livingSeed.auras; livingSeedRequested = livingSeed.healingAmount; if (health > 0 && livingSeedRequested > 0) { const effectiveSeedHealing = Math.min(livingSeedRequested, Math.max(0, state.maxHealth - health)); health += effectiveSeedHealing; livingSeedHealing = effectiveSeedHealing; } } let combatEvents = recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "damage", sourceActorId: "external", targetActorId: PLAYER_AURA_ENTITY_ID, school, rawAmount: incoming, effectiveAmount: healthDamage, overheal: 0, absorbed: namedAbsorb.absorbed + absorbed, critical: false, outcome: healthDamage > 0 ? "hit" : "absorb", }); if (livingSeedHealing > 0) { combatEvents = recordCombatEvent(combatEvents, { occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: DRUID_LIVING_SEED_ABILITY_ID, school: "nature", rawAmount: livingSeedRequested, effectiveAmount: livingSeedHealing, overheal: livingSeedRequested - livingSeedHealing, absorbed: 0, critical: false, outcome: "hit", }); } if (damageTransferPet && petAbsorb && damageTransferredToPet > 0) { combatEvents = recordCombatEvent(combatEvents, { occurredAt: now, kind: "damage", sourceActorId: "external", targetActorId: damageTransferPet.id, school, rawAmount: damageTransferredToPet, effectiveAmount: petHealthDamage, overheal: 0, absorbed: petAbsorb.absorbed, critical: false, outcome: petHealthDamage > 0 ? "hit" : "absorb", }); } if (soulLinkDamage.demon && soulLinkDamage.demonDamage > 0) { combatEvents = recordCombatEvent(combatEvents, { occurredAt: now, kind: "damage", sourceActorId: "external", targetActorId: soulLinkDamage.demon.id, abilityId: "wow335-warlock-soul-link", school, rawAmount: soulLinkDamage.demonDamage, effectiveAmount: soulLinkDamage.demonDamage, overheal: 0, absorbed: 0, critical: false, outcome: "hit", }); } set({ auras: health <= 0 ? nextAuras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID && (aura.definition.utilityTags?.includes("wow335-rogue-killing-spree") || aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)))) : nextAuras, cooldowns: health <= 0 ? Object.fromEntries(Object.entries(state.cooldowns) .filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)) : state.cooldowns, killingSpreeWindows: health <= 0 ? state.killingSpreeWindows.filter((window) => window.ownerActorId !== PLAYER_AURA_ENTITY_ID) : state.killingSpreeWindows, effects: accumulateLightwellDamageCancellation(state.effects, PLAYER_AURA_ENTITY_ID, healthDamage), summons: nextSummons, actors: nextActors, romNativeRecoveryClock: health <= 0 ? undefined : state.romNativeRecoveryClock, shield: state.shield - absorbed, health, resource: deathKnightResource.resource, resourcePools: deathKnightResource.resourcePools, combatEvents, activeCast: health <= 0 ? null : healthDamage > 0 ? applyWrathCastPushback(state.activeCast, now, `external:${school}:${state.health}`) : state.activeCast, autoAttackTargetId: health <= 0 ? null : state.autoAttackTargetId, nextAutoAttackAt: health <= 0 ? 0 : state.nextAutoAttackAt, nextOffhandAutoAttackAt: health <= 0 ? 0 : state.nextOffhandAutoAttackAt, autoShotTargetId: health <= 0 ? null : state.autoShotTargetId, nextAutoShotAt: health <= 0 ? 0 : state.nextAutoShotAt, playerAnimationEvent: healthDamage > 0 ? nextAnimationEvent(health <= 0 ? "death" : "wound") : state.playerAnimationEvent, feedback: nextFeedback("damage", preventedDeath ? "Remodeled Body prevented defeat and restored all health." : health <= 0 ? "You have been defeated." : `${healthDamage} damage taken.`, { amount: healthDamage }), }); return healthDamage; }, damageSummon: (id, amount) => { const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0)); if (requested <= 0) return 0; let applied = 0; set((state) => { const summon = state.summons.find((candidate) => candidate.id === id); if (!summon) return state; const now = combatNow(); const auraAdjusted = resolveAuraDamageValue( requested, aurasForEntity(state.auras, id, now), { direction: "taken", school: "physical", attackKind: "melee" }, ); const absorbed = absorbAuraDamage(state.auras, { targetId: id, amount: auraAdjusted, school: "physical", now, }); applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage))); const health = summon.health - applied; const summons = health > 0 ? state.summons.map((candidate) => candidate.id === id ? { ...candidate, health } : candidate) : state.summons.filter((candidate) => candidate.id !== id); const auras = summon.hunterPet?.prowl?.breaksOnIncomingDamage ? absorbed.auras.filter((aura) => !( aura.targetId === id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_PROWL_TAG) )) : absorbed.auras; const actors = { ...state.actors }; if (health > 0) actors[id] = combatActorFromSummon({ ...summon, health }, state.level, auras); else delete actors[id]; return { auras, summons, actors, combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: combatNow(), kind: "damage", sourceActorId: "external", targetActorId: id, rawAmount: requested, effectiveAmount: applied, overheal: 0, absorbed: absorbed.absorbed, critical: false, outcome: applied > 0 ? "hit" : "absorb", }), }; }); return applied; }, healPlayer: (amount) => { const state = get(); // Ordinary healing and lingering HoTs must not raise a defeated player. // Resurrection is an explicit combat action handled by revivePlayer. if (state.health <= 0) return 0; const modified = resolveAuraHealingValue( amount, aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID), "taken", ); const healed = Math.min(Math.max(0, Math.round(modified)), state.maxHealth - state.health); const procResult = evaluateAuraProcs(state.auras, { trigger: "heal", subjectId: PLAYER_AURA_ENTITY_ID, otherId: PLAYER_AURA_ENTITY_ID, amount: healed, }, combatNow(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`)); set({ auras: procResult.auras, health: state.health + healed, combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: combatNow(), kind: "healing", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: PLAYER_AURA_ENTITY_ID, rawAmount: Math.max(0, Math.round(modified)), effectiveAmount: healed, overheal: Math.max(0, Math.round(modified) - healed), absorbed: 0, critical: false, }), feedback: nextFeedback("heal", `${healed} health restored.`, { amount: healed }), }); return healed; }, revivePlayer: (percentMaxHealth = 0.35) => { const state = get(); if (state.health > 0 || state.maxHealth <= 0) return 0; const restored = Math.max(1, Math.round( state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)), )); const playerActor = state.actors[PLAYER_AURA_ENTITY_ID]; set({ health: restored, romNativeRecoveryClock: undefined, controlledUntil: 0, controlMechanic: null, playerAnimationEvent: nextAnimationEvent("ability-cancel"), actors: playerActor ? { ...state.actors, [PLAYER_AURA_ENTITY_ID]: { ...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null, } } : state.actors, feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }), }); return restored; }, respawnPlayerOnly: (position, now = combatNow()) => { const state = get(); if (state.health > 0 || state.maxHealth <= 0 || !Number.isFinite(now) || position.length !== 3 || !position.every(Number.isFinite)) return false; const auras = syncPassiveAuras(state.auras.filter((aura) => ( aura.targetId !== PLAYER_AURA_ENTITY_ID || (aura.definition.disposition === "buff" && !aura.definition.utilityTags?.includes("wow335-rogue-killing-spree")) )), state.abilities, state.level, state.talentRanks, now); const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, auras, state.gender, state.statRuleset); const patch: Partial = { health: maxHealth, maxHealth, romNativeRecoveryClock: undefined, resource: initialResourceForMaximum(state.classId, state.maxResource), resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [ type, type === resourceProfileForClass(state.classId).type ? initialResourceForMaximum(state.classId, maximum ?? state.maxResource) : 0, ])), controlledUntil: 0, controlMechanic: null, schoolLockedUntil: {}, shield: 0, shieldExpiresAt: 0, activeCast: null, pendingGroundTarget: null, autoAttackTargetId: null, nextAutoAttackAt: 0, nextOffhandAutoAttackAt: 0, autoShotTargetId: null, nextAutoShotAt: 0, comboPoints: 0, comboTargetId: null, playerPosition: [...position], playerAnimationEvent: nextAnimationEvent("ability-cancel"), effects: state.effects.filter((effect) => effect.targetId !== null && effect.targetId !== PLAYER_AURA_ENTITY_ID), auras, killingSpreeWindows: state.killingSpreeWindows.filter((window) => window.ownerActorId !== PLAYER_AURA_ENTITY_ID), combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "resurrection", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "player-checkpoint-resurrection", rawAmount: maxHealth, effectiveAmount: maxHealth, overheal: 0, absorbed: 0, critical: false, }), feedback: nextFeedback("success", "You have revived. Your party is still fighting; use Recall only if you want to regroup."), }; // Publish only the player snapshot; never replace or mirror companion/enemy actors. const player = combatActorSnapshots({ ...state, ...patch })[PLAYER_AURA_ENTITY_ID]; set({ ...patch, actors: { ...state.actors, [PLAYER_AURA_ENTITY_ID]: player } }); return true; }, cancelCast: (message = "Casting interrupted.", now = combatNow()) => { const cast = get().activeCast; if (!cast) return false; set((state) => ({ activeCast: null, ...romPriestChampionRageControlAwardPatch(state, cast, now), combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "interrupt", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: cast.abilityId, school: cast.school, rawAmount: 0, effectiveAmount: 0, overheal: 0, absorbed: 0, critical: false, outcome: "cancelled", }), playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId), feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }), })); emitPlayerAbilityCancellation(cast.abilityId, now, get().playerPosition); return true; }, engageNearbyMobs: (now = combatNow(), playerPosition) => { const state = get(); const runtimeState = playerPosition ? { ...state, playerPosition } : state; const mobs: Record = { ...state.mobs }; let engagedGroups = 0; for (const id of Object.keys(mobs)) { const mob = mobs[id]; if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0 || mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue; const position = getMobPosition(id); if (!position) continue; const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange); if (!actorId) continue; if (engageMobGroup(mobs, id, now, actorId)) engagedGroups += 1; } if (engagedGroups > 0) set({ mobs }); return engagedGroups; }, resetMobForNavigation: (id, atHome, now = combatNow()) => { const state = get(); const mob = state.mobs[id]; if (!mob || mob.dead || mob.despawned || !Number.isFinite(now)) return false; set({ mobs: { ...state.mobs, [id]: { ...mob, ...resetEnemyWeaponAttackState(mob), engaged: false, combatPhase: atHome ? "idle" : "returning", navigationEvadeUntil: Math.max(mob.navigationEvadeUntil ?? 0, now + 10_000), health: mob.maxHealth, statuses: [], controlDiminishingReturns: {}, tauntDiminishingReturns: { applications: 0, resetAt: 0 }, auras: [], abilityReadyAt: {}, abilityCastCounts: {}, meleeAttackSpeedMultiplier: 1, activeCast: null, schoolLockedUntil: {}, nextAttackAt: 0, nextAttackIndex: 0, threatByActor: {}, targetActorId: null, forcedTarget: null, } }, effects: state.effects.filter((effect) => effect.targetId !== id && effect.sourceActorId !== id), auras: removeEnemyActorAuras(state.auras, id), areaEffects: state.areaEffects.filter((area) => area.sourceActorId !== id), }); return true; }, beginKillingSpree: (ownerActorId, ability, targetIds, hands, role, now) => { if (ability.name !== "Killing Spree" || targetIds.length === 0) return false; const state = get(); let attackerStats: CharacterStats; let level: number; if (ownerActorId === PLAYER_AURA_ENTITY_ID) { if (baseClassFor(state.classId) !== "rogue" || state.health <= 0) return false; level = state.level; attackerStats = playerCharacterStats( state.classId, level, state.gearStats, state.raceId, state.secondaryClassId, state.talentModifiers, state.gender, state.statRuleset, ); } else { const member = usePartyStore.getState().members.find((candidate) => candidate.id === ownerActorId); const actor = state.actors[ownerActorId]; if (!member || baseClassFor(member.classId) !== "rogue" || member.health <= 0 || !actor?.alive) return false; level = member.level; attackerStats = playerCharacterStats( member.classId, level, member.gearStats, member.raceId, member.secondaryClassId ?? null, characterStatModifiers(member.classId, level, member.talentRanks), member.gender, member.statRuleset ?? "wow", ); } const durationMs = WOW335_ROGUE_KILLING_SPREE_DURATION_MS; const definition = auraDefinitionForAbility({ id: "wow335:rogue:killing-spree-damage", name: ability.name, description: "Increases all damage dealt by 20% during Killing Spree.", disposition: "buff", durationMs, maxStacks: 1, stackBehavior: "refresh", modifiers: [{ kind: "damage", direction: "dealt", operation: "percent", value: 0.2 }], utilityTags: ["wow335-rogue-killing-spree"], }, ability, durationMs); let auras = applyCombatAura(state.auras, { definition, sourceId: ownerActorId, targetId: ownerActorId, now, }).auras; // AuraEffect's final 2-second periodic callback runs before aura removal. auras = auras.map((aura) => aura.sourceId === ownerActorId && aura.targetId === ownerActorId && aura.definition.utilityTags?.includes("wow335-rogue-killing-spree") ? { ...aura, expiresAt: now + durationMs + 1 } : aura); const window = createWow335KillingSpreeWindow({ ownerActorId, abilityId: ability.id, role, now, targetIds, hands, attackPower: attackerStats.attackPower, fallbackDamage: playerCombatPower(level) * 0.35, }); set({ auras, killingSpreeWindows: [ ...state.killingSpreeWindows.filter((candidate) => candidate.ownerActorId !== ownerActorId), window, ], }); return true; }, advanceKillingSpree: (now) => { const ownerIsAlive = (state: CombatState, ownerActorId: string): boolean => { if (ownerActorId === PLAYER_AURA_ENTITY_ID) return state.health > 0 && baseClassFor(state.classId) === "rogue"; const member = usePartyStore.getState().members.find((candidate) => candidate.id === ownerActorId); const actor = state.actors[ownerActorId]; return Boolean(member && baseClassFor(member.classId) === "rogue" && member.health > 0 && actor?.alive); }; const targetIsValid = (state: CombatState, targetId: string, at: number): boolean => { const mob = state.mobs[targetId]; return Boolean(mob && !mob.dead && mob.health > 0 && !aurasForEntity(state.auras, targetId, at).some((aura) => ( aura.definition.utilityTags?.some((tag) => tag === "stealth" || tag === "invisible") ))); }; let state = get(); const validWindows = state.killingSpreeWindows.filter((window) => ownerIsAlive(state, window.ownerActorId)); if (validWindows.length !== state.killingSpreeWindows.length) { const validOwners = new Set(validWindows.map((window) => window.ownerActorId)); set({ killingSpreeWindows: validWindows, auras: state.auras.filter((aura) => !( aura.definition.utilityTags?.includes("wow335-rogue-killing-spree") && !validOwners.has(aura.sourceId) )), }); } const finishedOwners = new Set(); while (true) { state = get(); const due = state.killingSpreeWindows .filter((window) => window.nextAssaultAt <= now) .sort((left, right) => left.nextAssaultAt - right.nextAssaultAt || left.ownerActorId.localeCompare(right.ownerActorId))[0]; if (!due) break; const assaultAt = due.nextAssaultAt; const auraActive = state.auras.some((aura) => aura.sourceId === due.ownerActorId && aura.targetId === due.ownerActorId && aura.definition.utilityTags?.includes("wow335-rogue-killing-spree") && (aura.expiresAt === null || aura.expiresAt > assaultAt)); if (!ownerIsAlive(state, due.ownerActorId) || !auraActive) { set((current) => ({ killingSpreeWindows: current.killingSpreeWindows.filter((window) => window.ownerActorId !== due.ownerActorId), auras: current.auras.filter((aura) => !(aura.sourceId === due.ownerActorId && aura.targetId === due.ownerActorId && aura.definition.utilityTags?.includes("wow335-rogue-killing-spree"))), })); continue; } const eligibleTargetIds = due.targetIds.filter((targetId) => targetIsValid(state, targetId, assaultAt)); const advanced = advanceWow335KillingSpreeAssault(due, eligibleTargetIds); if (!advanced) { set((current) => ({ killingSpreeWindows: current.killingSpreeWindows.filter((window) => window.ownerActorId !== due.ownerActorId), })); continue; } set((current) => ({ killingSpreeWindows: current.killingSpreeWindows.flatMap((window) => ( window.ownerActorId !== due.ownerActorId ? [window] : advanced.window ? [advanced.window] : [] )), })); if (!advanced.window) finishedOwners.add(due.ownerActorId); const targetId = selectWow335KillingSpreeTarget( eligibleTargetIds, stableCombatUnit(`${due.ownerActorId}:${due.abilityId}:${due.startedAt}:killing-spree:${advanced.assault.index}`), ); if (!targetId) continue; const targetTransform = getMobRuntimeTransform(targetId); const targetPosition = targetTransform?.position ?? getMobPosition(targetId); if (targetPosition) { const currentOwnerPosition = due.ownerActorId === PLAYER_AURA_ENTITY_ID ? state.playerPosition : getPartyRuntimePosition(due.ownerActorId) ?? state.playerPosition; const targetYaw = targetTransform?.yaw ?? Math.atan2( targetPosition[0] - currentOwnerPosition[0], targetPosition[2] - currentOwnerPosition[2], ); const behindPosition: CombatPosition = [ targetPosition[0] - Math.sin(targetYaw) * 1.5, targetPosition[1], targetPosition[2] - Math.cos(targetYaw) * 1.5, ]; const facingYaw = Math.atan2( targetPosition[0] - behindPosition[0], targetPosition[2] - behindPosition[2], ); if (due.ownerActorId === PLAYER_AURA_ENTITY_ID) { set({ playerPosition: behindPosition }); useGameStore.getState().relocatePlayer({ footPosition: behindPosition, yaw: facingYaw }); } else requestPartyRuntimeCombatRelocation(due.ownerActorId, behindPosition, facingYaw); } for (const hit of due.hits) { const current = get(); const mob = current.mobs[targetId]; if (!mob || mob.dead || mob.health <= 0) break; current.damageMob(targetId, hit.damage, advanced.assault.at, { actorId: due.ownerActorId, role: due.role, abilityId: due.abilityId, }, "physical", { attackKind: "melee", seed: `${due.ownerActorId}:${due.abilityId}:${advanced.assault.index}:${hit.hand}:${targetId}:${advanced.assault.at}`, abilityId: due.abilityId, weaponSpeedMs: hit.speedMs, dbcSpellId: hit.dbcSpellId, }); } } state = get(); const activeAuras = state.auras.filter((aura) => !( aura.definition.utilityTags?.includes("wow335-rogue-killing-spree") && (finishedOwners.has(aura.sourceId) || aura.expiresAt !== null && aura.expiresAt <= now) )); if (activeAuras.length !== state.auras.length) set({ auras: activeAuras }); }, advanceMobCombat: (now = combatNow(), playerPosition) => { get().advanceKillingSpree(now); const state = get(); const runtimeState = playerPosition ? { ...state, playerPosition } : state; const mobs: Record = { ...state.mobs }; let namedAuras = state.auras.filter((aura) => !aura.definition.id.startsWith("enemy-source-spell-") || aura.expiresAt === null || aura.expiresAt > now); const incomingHits: Array<{ readonly actorId: AggroActorId; readonly amount: number; readonly mob: MobCombatState; readonly attack: EnemyAttackDefinition; readonly attackRevision: number; readonly instantCast: boolean; readonly nativeSourceDirect?: RomNativeEnemyDirectDamageValue; readonly targetOrdinal?: number; }> = []; const enemyPeriodicEffects: EnemyPeriodicCombatEffect[] = []; for (const periodic of state.enemyPeriodicEffects) { const actorAlive = periodic.targetActorId === PLAYER_AGGRO_ID ? state.health > 0 : state.actors[periodic.targetActorId]?.alive; const scheduledEnd = periodic.nextTickAt + (periodic.remainingTicks - 1) * periodic.intervalMs; if (!actorAlive || (now < scheduledEnd && !state.auras.some((aura) => aura.definition.id === periodic.id && aura.targetId === periodic.targetActorId && (aura.expiresAt === null || aura.expiresAt >= now)))) continue; let remainingTicks = periodic.remainingTicks; let nextTickAt = periodic.nextTickAt; while (remainingTicks > 0 && nextTickAt <= now) { const currentMaxHealth = periodic.targetActorId === PLAYER_AGGRO_ID ? state.maxHealth : state.actors[periodic.targetActorId]?.maxHealth ?? 0; const tickAmount = periodic.nativeSnapshot ? nativePeriodicDamageTick(periodic.nativeSnapshot, currentMaxHealth)?.damage ?? 0 : periodic.amount; incomingHits.push({ actorId: periodic.targetActorId, amount: tickAmount, mob: state.mobs[periodic.sourceMob.id] ?? periodic.sourceMob, attack: { ...periodic.attack, periodicTick: true, ...(periodic.nativeSnapshot ? { targetMitigationSnapshotted: true, periodicNoKill: periodic.nativeSnapshot.isDotMagicNoKill } : {}) }, attackRevision: periodic.attackRevision + periodic.remainingTicks - remainingTicks, instantCast: false }); remainingTicks -= 1; nextTickAt += periodic.intervalMs; } if (remainingTicks > 0) enemyPeriodicEffects.push({ ...periodic, remainingTicks, nextTickAt }); } const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = []; let playerControl: { readonly mechanic: EnemyControlMechanic; readonly endsAt: number } | null = null; const memberControls: Array<{ readonly memberId: string; readonly mechanic: EnemyControlMechanic; readonly endsAt: number; }> = []; let changed = namedAuras.length !== state.auras.length; for (const [id, sourceMob] of Object.entries(state.mobs)) { if (sourceMob.dead || sourceMob.despawned) continue; // AzerothCore clears _lastDamagedTargetGuid at the start of each unit // update, before any queued extra attacks are drained. const mob = sourceMob.lastDamagedActorId == null ? sourceMob : { ...sourceMob, lastDamagedActorId: null }; if (mob !== sourceMob) { mobs[id] = mob; changed = true; } if (!mob.engaged) { if (mob.combatPhase !== "idle" || (mob.navigationEvadeUntil ?? 0) > now) continue; if (!mob.attacks.some((attack) => (attack.activation === "out-of-combat" || attack.activation === "always") && (!attack.maximumCasts || (mob.abilityCastCounts[attack.id] ?? 0) < attack.maximumCasts))) continue; const readyAt = { ...mob.abilityReadyAt }; const counts = { ...mob.abilityCastCounts }; let prepared = false; let dualWieldEnabled = supportsEnemyCreatureWeaponAttacks(mob) && (mob.dualWieldEnabled ?? false); let nextEnemyOffhandAttackAt = mob.nextEnemyOffhandAttackAt ?? 0; for (const attack of mob.attacks) { if (attack.activation !== "out-of-combat" && attack.activation !== "always" || attack.target !== "self" && attack.target !== "selected-friendly" || attack.maximumCasts && (counts[attack.id] ?? 0) >= attack.maximumCasts) continue; const capabilityEffect = attack.effect?.kind === "weapon-capability" && attack.effect.capability === "dual-wield" && enemyDualWieldContract(attack.effect.sourceSpellId, attack.effect.sourceProfile); const auraEffect = attack.effect?.kind === "aura" ? attack.effect : undefined; if (!capabilityEffect && !auraEffect?.wowAuraSource) continue; if (readyAt[attack.id] === undefined) { readyAt[attack.id] = now + deterministicEnemyCooldown(id, attack.id, counts[attack.id] ?? 0, attack.initialCooldownMs ?? [450, 450]); prepared = true; } if (now < readyAt[attack.id] || !enemyAbilityConditionMet(mob, mobs, attack, namedAuras, now) || mob.activeCast || mob.statuses.some((status) => status.endsAt > now && ["stun", "fear", "sleep", "blind", "confuse", "freeze", "knockdown"].includes(status.kind))) continue; if (capabilityEffect) { if (!supportsEnemyCreatureWeaponAttacks(mob)) continue; if (!dualWieldEnabled) { dualWieldEnabled = true; // Out-of-combat creatures enter through Unit::Attack, which // starts the enabled offhand at half of the base swing time. nextEnemyOffhandAttackAt = 0; } } else if (auraEffect?.wowAuraSource) { const contract = enemySourceAuraContract(auraEffect.wowAuraSource.spellId, auraEffect.wowAuraSource.sourceProfile); const definition = contract && supportedEnemySourceAuraDefinition(contract); if (!definition) continue; const recipient = attack.target === "self" ? id : selectedEnemyFriendlyRecipient(mob, mobs, attack, namedAuras, now); if (!recipient) continue; if (!((attack.source?.castFlags ?? 0) & 32) || !aurasForEntity(namedAuras, recipient, now).some((aura) => aura.definition.id === definition.id)) { namedAuras = [...applyEnemySourceAura(namedAuras, definition, id, recipient, now)]; } } counts[attack.id] = (counts[attack.id] ?? 0) + 1; readyAt[attack.id] = now + deterministicEnemyCooldown(id, attack.id, counts[attack.id], attack.repeatCooldownMs ?? [attack.cooldownMs, attack.cooldownMs]); prepared = true; } if (prepared) { mobs[id] = { ...mob, abilityReadyAt: readyAt, abilityCastCounts: counts, dualWieldEnabled, nextEnemyOffhandAttackAt, lastDamagedActorId: null }; changed = true; } continue; } const position = getMobPosition(id); if (!position) continue; const home = mob.homePosition ?? position; const eligible = eligibleAggroActorIds(runtimeState, { ...mob, homePosition: home }); const threatByActor = pruneThreatTable(mob.threatByActor, eligible); const resolution = resolveAggroTarget( threatByActor, eligible, mob.targetActorId, mob.forcedTarget, now, ); const targetPosition = resolution.targetActorId ? actorPosition(runtimeState, resolution.targetActorId) : null; if (!resolution.targetActorId || !targetPosition) { mobs[id] = { ...mob, ...resetEnemyWeaponAttackState(mob), engaged: false, combatPhase: "returning", nextAttackAt: 0, health: mob.maxHealth, statuses: [], controlDiminishingReturns: {}, tauntDiminishingReturns: { applications: 0, resetAt: 0 }, auras: [], abilityReadyAt: {}, abilityCastCounts: {}, meleeAttackSpeedMultiplier: 1, activeCast: null, schoolLockedUntil: {}, threatByActor: {}, targetActorId: null, forcedTarget: null, }; namedAuras = [...removeEnemyActorAuras(namedAuras, id)]; changed = true; continue; } const activeStatuses = mob.statuses.filter((status) => status.endsAt > now); const cannotAttack = activeStatuses.some((status) => [ "stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse", ].includes(status.kind)); const silenced = activeStatuses.some((status) => status.kind === "silence"); const disarmed = activeStatuses.some((status) => status.kind === "disarm"); const rooted = activeStatuses.some((status) => status.kind === "root"); const dx = position[0] - targetPosition[0]; const dy = position[1] - targetPosition[1]; const dz = position[2] - targetPosition[2]; const distanceSquared = dx * dx + dy * dy + dz * dz; const targetVisible = combatHasLineOfSight(position, targetPosition); const abilityReadyAt = Object.keys(mob.abilityReadyAt).length ? { ...mob.abilityReadyAt } : { ...initialEnemyAbilityReadyAt(mob, now) }; let meleeAttackSpeedMultiplier = mob.meleeAttackSpeedMultiplier ?? 1; let dualWieldEnabled = supportsEnemyCreatureWeaponAttacks(mob) && (mob.dualWieldEnabled ?? false); let nextEnemyOffhandAttackAt = mob.nextEnemyOffhandAttackAt ?? 0; let pendingExtraAttacks = [...(mob.pendingExtraAttacks ?? [])]; let lastDamagedActorId: string | null = null; const updateMeleeAttackSpeed = (): void => { const nextMultiplier = enemyMeleeHasteIntervalMultiplier(aurasForEntity(namedAuras, id, now)); if (nextMultiplier === meleeAttackSpeedMultiplier) return; for (const candidate of mob.attacks.filter(isEnemyMainHandAutoAttack)) { const readyAt = abilityReadyAt[candidate.id]; if (readyAt > now && Number.isFinite(readyAt)) { abilityReadyAt[candidate.id] = now + Math.round((readyAt - now) * nextMultiplier / meleeAttackSpeedMultiplier); } } if (dualWieldEnabled && nextEnemyOffhandAttackAt > now && Number.isFinite(nextEnemyOffhandAttackAt)) { nextEnemyOffhandAttackAt = now + Math.round( (nextEnemyOffhandAttackAt - now) * nextMultiplier / meleeAttackSpeedMultiplier, ); } meleeAttackSpeedMultiplier = nextMultiplier; }; updateMeleeAttackSpeed(); if (dualWieldEnabled && nextEnemyOffhandAttackAt <= 0) { nextEnemyOffhandAttackAt = now + Math.max(1, Math.round( mob.swingMs * meleeAttackSpeedMultiplier * ENEMY_DUAL_WIELD_FIRST_SWING_DELAY_FRACTION, )); } const abilityCastCounts = { ...mob.abilityCastCounts }; const schoolLockedUntil = Object.fromEntries( Object.entries(mob.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now), ) as Partial>; let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical") ? null : mob.activeCast; if (enemyActiveCast) { const pendingAttack = mob.attacks.find((attack) => attack.id === enemyActiveCast!.attackId); const pendingPosition = enemyActiveCast.targetActorId ? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null; if (pendingAttack && enemyAbilityNeedsHostileRange(pendingAttack) && (!pendingPosition || !combatHasLineOfSight(position, pendingPosition) || Math.hypot(position[0] - pendingPosition[0], position[1] - pendingPosition[1], position[2] - pendingPosition[2]) > pendingAttack.range)) { emitEnemyAttackPresentation(mob, pendingAttack, "cast-cancel", now, position, enemyActiveCast.targetActorId, pendingPosition); abilityReadyAt[pendingAttack.id] = now + 250; enemyActiveCast = null; } } let completingEnemyCast = false; let selectedAttackIndex = -1; let selectedAttack: EnemyAttackDefinition | null = null; if (enemyActiveCast && now >= enemyActiveCast.completesAt) { selectedAttackIndex = mob.attacks.findIndex((attack) => attack.id === enemyActiveCast!.attackId); selectedAttack = selectedAttackIndex >= 0 ? mob.attacks[selectedAttackIndex] : null; completingEnemyCast = selectedAttack !== null; if (!selectedAttack) enemyActiveCast = null; } else if (!enemyActiveCast && !cannotAttack) { for (let offset = 0; offset < mob.attacks.length; offset += 1) { const index = (mob.nextAttackIndex + offset) % mob.attacks.length; const attack = mob.attacks[index]; if (attack.activation === "out-of-combat") continue; const attackKind = enemyAttackKind(attack); const inAbilityRange = !enemyAbilityNeedsHostileRange(attack) || (targetVisible && distanceSquared <= attack.range * attack.range); if ( inAbilityRange && !(silenced && (attack.school !== "physical" || attackKind === "spell")) && !(disarmed && attackKind === "melee") && now >= (abilityReadyAt[attack.id] ?? 0) && now >= (schoolLockedUntil[attack.school] ?? 0) && (!attack.maximumCasts || (abilityCastCounts[attack.id] ?? 0) < attack.maximumCasts) && enemyAbilityConditionMet(mob, mobs, attack, namedAuras, now) ) { selectedAttackIndex = index; selectedAttack = attack; break; } } } // A self buff/heal must not pin an otherwise blocked attacker in place. const hostileAttacks = mob.attacks.filter(enemyAbilityNeedsHostileRange); const inRange = targetVisible && (hostileAttacks.length ? hostileAttacks.some((attack) => distanceSquared <= attack.range * attack.range) : distanceSquared <= mob.attackRange * mob.attackRange); if (selectedAttack && completingEnemyCast && enemyAbilityNeedsHostileRange(selectedAttack)) { const castTargetPosition = enemyActiveCast?.targetActorId ? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null; if (!castTargetPosition || !combatHasLineOfSight(position, castTargetPosition) || Math.hypot(position[0] - castTargetPosition[0], position[1] - castTargetPosition[1], position[2] - castTargetPosition[2]) > selectedAttack.range) { emitEnemyAttackPresentation(mob, selectedAttack, "cast-cancel", now, position, enemyActiveCast?.targetActorId ?? resolution.targetActorId, castTargetPosition); abilityReadyAt[selectedAttack.id] = now + 250; enemyActiveCast = null; selectedAttack = null; } } const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing"; let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY); let attackRevision = mob.attackRevision; let attackAnimation = mob.attackAnimation; let nextAttackIndex = mob.nextAttackIndex; let lastAttackId = mob.lastAttackId; let activeAuras = mob.auras.filter((aura) => aura.endsAt <= 0 || aura.endsAt > now); const startingEnemyCast = Boolean( selectedAttack && !completingEnemyCast && (selectedAttack.castTimeMs ?? 0) > 0, ); const retainedExtraAttacks = pendingExtraAttacks.filter((entry) => entry.queuedAt >= now); const dueExtraAttacks = pendingExtraAttacks.filter((entry) => entry.queuedAt < now); if (dueExtraAttacks.length) { for (const queued of dueExtraAttacks) { const queuedTargetPosition = actorPosition(runtimeState, queued.targetActorId); const queuedTargetAlive = queued.targetActorId === PLAYER_AGGRO_ID ? runtimeState.health > 0 : Boolean(runtimeState.actors[queued.targetActorId]?.alive); const queuedDistance = queuedTargetPosition ? Math.hypot(position[0] - queuedTargetPosition[0], position[1] - queuedTargetPosition[1], position[2] - queuedTargetPosition[2]) : Number.POSITIVE_INFINITY; const meleeRange = mob.attacks.find(isEnemyMainHandAutoAttack)?.range ?? DEFAULT_ENEMY_ATTACK.range; if (!supportsEnemyCreatureWeaponAttacks(mob) || cannotAttack || disarmed || !queuedTargetAlive || !queuedTargetPosition || queuedDistance > meleeRange || !combatHasLineOfSight(position, queuedTargetPosition)) continue; for (let swingIndex = 0; swingIndex < queued.remaining; swingIndex += 1) { attackRevision += 1; const extraAttack = enemyWeaponSwingAttack( [queued.id, "swing", swingIndex + 1].join(":"), "extra-mainhand", queued.sourceSpellId, ); const baselineDamage = Math.max(0, Math.round(mob.attackDamage)); const outgoingDamage = enemyDamageAfterTargetAuras( enemyAttackDamageAfterVindication(mob, extraAttack, baselineDamage, 1, now, namedAuras), namedAuras, id, extraAttack, now, ); emitEnemyAttackPresentation(mob, extraAttack, "release", now, position, queued.targetActorId, queuedTargetPosition, false); incomingHits.push({ actorId: queued.targetActorId, amount: outgoingDamage, mob, attack: extraAttack, attackRevision, instantCast: false, }); attackAnimation = "attack"; lastAttackId = extraAttack.id; } } pendingExtraAttacks = retainedExtraAttacks; } if (dualWieldEnabled && nextEnemyOffhandAttackAt > 0 && nextEnemyOffhandAttackAt <= now && inRange && !cannotAttack && !disarmed && !enemyActiveCast && !startingEnemyCast && resolution.targetActorId && targetPosition && distanceSquared <= Math.pow( mob.attacks.find(isEnemyMainHandAutoAttack)?.range ?? DEFAULT_ENEMY_ATTACK.range, 2, )) { const readyMainhand = mob.attacks.find((candidate) => isEnemyMainHandAutoAttack(candidate) && now >= (abilityReadyAt[candidate.id] ?? 0)); if (readyMainhand) { nextEnemyOffhandAttackAt = now + ENEMY_DUAL_WIELD_SIMULTANEOUS_SWING_DELAY_MS; } else { attackRevision += 1; const offhandAttack = enemyWeaponSwingAttack( ["offhand", id, attackRevision, now].join(":"), "offhand", undefined, 0.5, ); const baselineDamage = Math.max(0, Math.round(mob.attackDamage * 0.5)); const outgoingDamage = enemyDamageAfterTargetAuras( enemyAttackDamageAfterVindication(mob, offhandAttack, baselineDamage, 0.5, now, namedAuras), namedAuras, id, offhandAttack, now, ); emitEnemyAttackPresentation(mob, offhandAttack, "release", now, position, resolution.targetActorId, targetPosition, false); incomingHits.push({ actorId: resolution.targetActorId, amount: outgoingDamage, mob, attack: offhandAttack, attackRevision, instantCast: false, }); nextEnemyOffhandAttackAt = now + Math.max(1, Math.round(mob.swingMs * meleeAttackSpeedMultiplier)); attackAnimation = "attack"; lastAttackId = offhandAttack.id; } } if (selectedAttack && startingEnemyCast) { const attack = selectedAttack; const sourceTransform = getMobRuntimeTransform(id); enemyActiveCast = { attackId: attack.id, name: attack.name, ...(attack.spellId === undefined ? {} : { spellId: attack.spellId }), school: attack.school, interruptible: attack.interruptible !== false, targetActorId: resolution.targetActorId, sourcePosition: [...position], targetPosition: [...targetPosition], sourceYaw: sourceTransform?.yaw ?? Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]), startedAt: now, completesAt: now + (attack.castTimeMs ?? 0), }; attackAnimation = "cast"; lastAttackId = attack.id; nextAttackAt = enemyActiveCast.completesAt; emitEnemyAttackPresentation( mob, attack, "cast-start", now, position, resolution.targetActorId, targetPosition, ); } else if (selectedAttack && !cannotAttack) { const attack = selectedAttack!; const resolvedEnemyCast = enemyActiveCast; enemyActiveCast = null; const effect = attack.effect ?? { kind: "damage" as const, multiplier: attack.damageMultiplier, }; const sourceAreaAura = effect.kind === "target-aura" && effect.wowAuraSource; const triggeredShout = effect.kind === "triggered-shout" ? enemyTriggeredShoutContract(effect.sourceSpellId, effect.sourceProfile) : undefined; const eligibleActors = [...(sourceAreaAura || triggeredShout ? eligibleAggroActorIds(runtimeState, { homePosition: null, leashRange: mob.leashRange, }) : eligible)].sort(); const randomActor = eligibleActors.length ? eligibleActors[Math.floor( stableCombatUnit(`${id}:${attack.id}:${attackRevision}:target`) * eligibleActors.length, )] : resolution.targetActorId; const telegraphPlacement = resolvedEnemyCast ? { sourcePosition: resolvedEnemyCast.sourcePosition, targetPosition: resolvedEnemyCast.targetPosition, sourceYaw: resolvedEnemyCast.sourceYaw, } : { sourcePosition: position, targetPosition, sourceYaw: Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]), }; const selectedFriendlyId = attack.target === "selected-friendly" ? selectedEnemyFriendlyRecipient(mob, mobs, attack, namedAuras, now) : undefined; const unvalidatedTargets = attack.target === "selected-friendly" ? selectedFriendlyId ? [selectedFriendlyId] : [] : attack.target === "nearby-party" ? eligibleActors.filter((actorId) => { const candidate = actorPosition(runtimeState, actorId); if (!candidate) return false; if (attack.telegraph) { return enemyTelegraphContainsPosition(attack.telegraph, telegraphPlacement, candidate); } const radius = attack.radius ?? Math.max(1, attack.range * 0.35); const targetDx = candidate[0] - position[0]; const targetDy = candidate[1] - position[1]; const targetDz = candidate[2] - position[2]; return targetDx * targetDx + targetDy * targetDy + targetDz * targetDz <= radius * radius; }) : attack.target === "random-party" ? [randomActor] : attack.target === "primary" ? [attack.chain && resolvedEnemyCast?.targetActorId ? resolvedEnemyCast.targetActorId : resolution.targetActorId] : []; const enemySpatial = combatSpatialActor(id); const primaryTargets = unvalidatedTargets.filter((actorId) => { const candidate = attack.target === "selected-friendly" ? getMobPosition(actorId) : actorPosition(runtimeState, actorId); if (!candidate || !combatHasLineOfSight(position, candidate)) return false; return attack.target === "nearby-party" || attack.target === "selected-friendly" || !enemySpatial || isPositionInFacingArc(enemySpatial, candidate, Math.PI); }); const chainActorIds = attack.chain ? [...eligibleAggroActorIds(runtimeState, { homePosition: null, leashRange: mob.leashRange, })].sort() : []; const chainCandidates = chainActorIds.flatMap((actorId) => { const candidatePosition = actorPosition(runtimeState, actorId); const alive = actorId === PLAYER_AGGRO_ID ? runtimeState.health > 0 : Boolean(runtimeState.actors[actorId]?.alive); return candidatePosition ? [{ id: actorId, alive, position: candidatePosition }] : []; }); const chainHits = attack.chain ? selectEnemyChainTargets({ primaryTarget: chainCandidates.find((candidate) => candidate.id === primaryTargets[0]) ?? null, candidates: chainCandidates, maximumTargets: attack.chain.targetCount, jumpRange: attack.chain.jumpRange, damageMultiplierPerBounce: attack.chain.damageMultiplier, hasLineOfSight: combatHasLineOfSight, }) : []; const targets = attack.chain ? chainHits.map((hit) => hit.target.id) : primaryTargets; const effectTargets = targets.filter((actorId) => ( !deterrencePreventsIncomingSpellEffects(state, actorId, attack, now) )); const presentationTargetId = attack.target === "self" || attack.target === "nearby-friendly" ? id : targets[0] ?? resolution.targetActorId; const presentationTargetPosition = attack.target === "self" || attack.target === "nearby-friendly" ? position : attack.target === "selected-friendly" ? getMobPosition(presentationTargetId) : actorPosition(runtimeState, presentationTargetId); const instantSourceCast = !resolvedEnemyCast && attack.spellId !== undefined; emitEnemyAttackPresentation( mob, attack, "release", now, position, presentationTargetId, presentationTargetPosition, instantSourceCast, ); const effects = effect.kind === "composite" ? effect.effects : [effect]; let hasDamageResolution = false; for (const effect of effects) { if (effect.kind === "weapon-capability") { const contract = effect.capability === "dual-wield" ? enemyDualWieldContract(effect.sourceSpellId, effect.sourceProfile) : undefined; if (!supportsEnemyCreatureWeaponAttacks(mob) || !contract || attack.spellId !== effect.sourceSpellId) continue; if (!dualWieldEnabled) { dualWieldEnabled = true; nextEnemyOffhandAttackAt = now + ENEMY_DUAL_WIELD_SIMULTANEOUS_SWING_DELAY_MS; } continue; } else if (effect.kind === "extra-attacks") { const contract = effect.sourceSpellId === 3391 ? enemyThrashContract(effect.sourceProfile) : undefined; if (!supportsEnemyCreatureWeaponAttacks(mob) || !contract || attack.spellId !== effect.sourceSpellId || effect.count !== contract.extraAttackCount) continue; for (const hit of incomingHits) { if (hit.mob.id !== id) continue; lastDamagedActorId = trackEnemyLastDamagedTarget(lastDamagedActorId, { targetActorId: hit.actorId, isWeaponSwing: isEnemyWeaponSwing(hit.attack), periodicTick: Boolean(hit.attack.periodicTick), sourceDamage: hit.amount, }); } const targetActorId = resolveEnemyThrashTarget(lastDamagedActorId, resolution.targetActorId); if (!targetActorId) continue; pendingExtraAttacks = [...enqueueEnemyThrashExtraAttacks(pendingExtraAttacks, { id: ["thrash", id, attack.id, attackRevision, now].join(":"), sourceSpellId: contract.spellId, targetActorId, queuedAt: now, remaining: contract.extraAttackCount, })]; continue; } else if (effect.kind === "dot") { hasDamageResolution = true; const wowPeriodic = effect.wowPeriodicSource ? enemyPeriodicDamageSource(effect.wowPeriodicSource.spellId, effect.wowPeriodicSource.sourceProfile) : undefined; const wowTick = wowPeriodic ? enemyPeriodicDamageTickAmount(mob.serverEntry ?? 0, mob.level, wowPeriodic) : undefined; if (effect.wowPeriodicSource && (!wowPeriodic || wowTick?.status !== "resolved")) continue; const periodicSource = mob.runewakerNpcId && attack.spellId && effect.componentId ? romNativePeriodicDamageBinding({ spellId: attack.spellId, componentId: effect.componentId, npcId: mob.runewakerNpcId, kind: "dot" }) : undefined; const periodicSchool: DamageSchool | undefined = periodicSource ? (["nature", "frost", "fire", "nature", "holy", "shadow"] as const)[periodicSource.component.magicType] ?? (periodicSource.component.effectType === 1 ? "physical" : undefined) : undefined; const periodicAttack: EnemyAttackDefinition = wowPeriodic ? { ...attack, school: wowPeriodic.school, attackKind: wowPeriodic.attackKind, periodicIgnoresArmor: wowPeriodic.periodicIgnoresArmor, outgoingDamageSnapshotted: true } : periodicSource ? { ...attack, school: periodicSchool ?? attack.school, runewakerEffectType: periodicSource.component.effectType, runewakerComponentId: periodicSource.component.componentId, attackKind: periodicSource.component.effectType === 0 ? "spell" : "melee" } : attack; for (const actorId of effectTargets) { if (wowPeriodic) { const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId); const applicationDefender = actorId === PLAYER_AGGRO_ID ? playerCharacterStats(runtimeState.classId, runtimeState.level, runtimeState.gearStats, runtimeState.raceId, runtimeState.secondaryClassId, runtimeState.talentModifiers, runtimeState.gender, runtimeState.statRuleset) : member ? deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow") : undefined; const applicationAuras = aurasForEntity(namedAuras, actorId, now); if (applicationDefender && !resolveEnemyDamageResult(mob, attack, applyAuraStats(applicationDefender, applicationAuras), 0, actorId, attackRevision, now, 0, applicationAuras, undefined, 0, true).landed) continue; } const nativeSnapshot = nativeIncomingPeriodicSnapshot(mob, periodicAttack, effect, actorId, runtimeState, namedAuras, now, attackRevision); if (nativeSnapshot === null) continue; const periodicId = `enemy-dot:${id}:${wowPeriodic ? `spell-${wowPeriodic.spellId}` : attack.id}:${effect.componentId ?? 0}`; const intervalMs = nativeSnapshot ? nativeSnapshot.tickIntervalSeconds * 1_000 : effect.intervalMs; const remainingTicks = nativeSnapshot?.ticks ?? effect.ticks; const durationMs = intervalMs * remainingTicks; const existingIndex = enemyPeriodicEffects.findIndex((entry) => entry.id === periodicId && entry.targetActorId === actorId); if (existingIndex >= 0) enemyPeriodicEffects.splice(existingIndex, 1); enemyPeriodicEffects.push({ id: periodicId, sourceMob: mob, attack: periodicAttack, targetActorId: actorId, amount: wowTick?.status === "resolved" ? enemyDamageAfterTargetAuras(Math.max(0, Math.round(wowTick.tickAmount * mob.damageMultiplier)), namedAuras, id, periodicAttack, now) : Math.max(0, Math.round(mob.attackDamage * effect.multiplier)), remainingTicks, intervalMs, nativeSnapshot: nativeSnapshot ?? undefined, nextTickAt: now + intervalMs, attackRevision }); namedAuras = [...applyCombatAura(namedAuras, { definition: { id: periodicId, name: attack.name, disposition: "debuff", durationMs, ...(wowPeriodic ? {} : { dispelCategory: "magic" as const }), stackBehavior: "replace", }, sourceId: id, targetId: actorId, now }).auras]; } } else if (effect.kind === "damage") { hasDamageResolution = true; const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier)); const attackDamage = attack.runewakerFormula ? adaptRunewakerEnemyDamage({ formula: attack.runewakerFormula, baselineDamage: mob.attackDamage, }).amount : enemyAttackDamageAfterVindication(mob, attack, baselineDamage, effect.multiplier, now, namedAuras); for (const [targetOrdinal, actorId] of targets.entries()) { const componentId = attack.runewakerComponentId ?? attack.runewakerFormula?.componentId; const sourceIdentityVerified = !attack.periodicTick && mob.runewakerNpcId && attack.spellId && componentId; const targetStats = sourceIdentityVerified ? nativeHealingTargetStats(actorId, runtimeState, runtimeState.level, namedAuras, now) : undefined; const absorb = targetStats ? romNativeTrainingEnemyAbsorbInputs(targetStats, nativeEnemyAbsorbAurasVerifiedNeutral(aurasForEntity(namedAuras, actorId, now))) : undefined; const source = sourceIdentityVerified && mob.runewakerNpcId && attack.spellId && componentId && targetStats && nativePlayerCombatant(targetStats) && nativeMobCombatant(mob) ? resolveRomNativeEnemyDirectDamage({ npcId: mob.runewakerNpcId, npcLevel: mob.level, sourceSkillId: attack.spellId, componentId, targetMaxHp: actorId === PLAYER_AGGRO_ID ? runtimeState.maxHealth : runtimeState.actors[actorId]?.maxHealth ?? targetStats.health.max, targetCount: targets.length, targetOrdinal, targetAbsorb: absorb?.status === "supported" ? absorb.value : undefined, // Supported source equipment rejects nonzero unmodeled AoE power; // BodyRate starts at 1. Active NPC auras still require verification. sourceAoeMagicPowerRate: mob.auras.length === 0 && aurasForEntity(namedAuras, id, now).length === 0 ? 1 : undefined }) : undefined; const nativeSourceDirect = source?.status === "supported" ? source.value : undefined; const sourceAmount = nativeSourceDirect ? Math.max(0, Math.round(nativeSourceDirect.amount * mob.damageMultiplier)) : attackDamage; incomingHits.push({ actorId, amount: Math.max(0, Math.round(sourceAmount * (chainHits[targetOrdinal]?.damageMultiplier ?? 1))), mob, attack, attackRevision, instantCast: instantSourceCast, nativeSourceDirect, targetOrdinal, }); } } else if (effect.kind === "leech") { hasDamageResolution = true; const attackDamage = enemyAttackDamageAfterVindication( mob, attack, Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier)), effect.damageMultiplier, now, namedAuras, ); for (const actorId of targets) { incomingHits.push({ actorId, amount: attackDamage, mob, attack, attackRevision, instantCast: instantSourceCast, }); } if (effectTargets.length) { enemyHealing.push({ targetId: id, amount: Math.max(1, Math.round(mob.attackDamage * effect.healMultiplier)), }); } } else if (effect.kind === "heal") { const requiredMissingHealth = attack.condition?.kind === "friendly-missing-health" ? attack.condition.amount : 1; const friendlyRange = attack.condition?.kind === "friendly-missing-health" ? attack.condition.range : attack.range; const friendly = Object.values(mobs) .filter((candidate) => { if (candidate.dead || candidate.health > candidate.maxHealth - requiredMissingHealth) { return false; } const candidatePosition = getMobPosition(candidate.id); if (!candidatePosition) return false; const friendlyDx = candidatePosition[0] - position[0]; const friendlyDy = candidatePosition[1] - position[1]; const friendlyDz = candidatePosition[2] - position[2]; return friendlyDx * friendlyDx + friendlyDy * friendlyDy + friendlyDz * friendlyDz <= friendlyRange * friendlyRange && combatHasLineOfSight(position, candidatePosition); }) .sort((left, right) => ( (left.health / Math.max(1, left.maxHealth)) - (right.health / Math.max(1, right.maxHealth)) || left.id.localeCompare(right.id) ))[0]; if (friendly) { enemyHealing.push({ targetId: friendly.id, amount: Math.max(1, Math.round(mob.attackDamage * effect.multiplier)), }); } } else if (effect.kind === "aura") { const sourceAura = effect.wowAuraSource ? enemySourceAuraContract(effect.wowAuraSource.spellId, effect.wowAuraSource.sourceProfile) : undefined; const criticalBlock = effect.wowAuraSource ? enemyBlockAuraContract(effect.wowAuraSource.spellId, effect.wowAuraSource.sourceProfile) : undefined; // This adapter models the audited default Creature spellmod path; // arbitrary server aura scripts/modules are not loaded by this runtime. const sourceDefinition = sourceAura ? supportedEnemySourceAuraDefinition(sourceAura) : criticalBlock && attack.target === "self" && attack.spellId === criticalBlock.spellId && supportsEnemyCreatureWeaponAttacks(mob) && creatureTemplateReference(mob.serverEntry!) !== null ? ordinaryCreatureSelfTargetCriticalBlockAuraDefinition(criticalBlock, { defenderId: id, casterId: id, targetId: id, defenderKind: "ordinary-creature", isNativeDefender: false, isSummonedDefender: false, isCharacterDefender: false, aura107Applicability: "azerothcore-e822d718-default-spellmod-path", }) : undefined; if (effect.wowAuraSource && !sourceDefinition) continue; if (sourceDefinition) { const recipients = attack.target === "selected-friendly" ? targets : attack.target === "nearby-friendly" ? Object.values(mobs).filter((candidate) => { const friendlyPosition = getMobPosition(candidate.id); return !candidate.dead && friendlyPosition && Math.hypot(friendlyPosition[0] - position[0], friendlyPosition[1] - position[1], friendlyPosition[2] - position[2]) <= (attack.radius ?? 0) && combatHasLineOfSight(position, friendlyPosition); }).map((candidate) => candidate.id) : [id]; for (const recipient of recipients) { if (((attack.source?.castFlags ?? 0) & 32) && aurasForEntity(namedAuras, recipient, now).some((aura) => aura.definition.id === sourceDefinition.id)) continue; namedAuras = [...applyEnemySourceAura(namedAuras, sourceDefinition, id, recipient, now)]; } // Reviewed source auras use the shared authoritative aura array. updateMeleeAttackSpeed(); continue; } const endsAt = effect.durationMs > 0 ? now + effect.durationMs : 0; activeAuras = [ ...activeAuras.filter((aura) => aura.id !== effect.aura), { id: effect.aura, sourceAbilityId: attack.id, endsAt, ...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }), ...(effect.lifeStealPercent === undefined ? {} : { lifeStealPercent: effect.lifeStealPercent }), ...(effect.meleeAutoCriticalProc === undefined ? {} : { meleeAutoCriticalProc: effect.meleeAutoCriticalProc }), }, ]; namedAuras = [...applyCombatAura(namedAuras, { definition: { id: `enemy:${id}:${effect.aura}`, name: `${attack.name}: ${effect.aura}`, disposition: "buff", durationMs: effect.durationMs > 0 ? effect.durationMs : null, dispelCategory: effect.aura === "enrage" ? "enrage" : "magic", ...(effect.damageTakenMultiplier === undefined ? {} : { modifiers: [{ kind: "damage" as const, direction: "taken" as const, operation: "percent" as const, value: effect.damageTakenMultiplier - 1, ...(effect.damageSchool ? { school: effect.damageSchool } : {}), }], }), ...(effect.absorbMultiplier === undefined ? {} : { absorbs: [{ id: `${attack.id}:absorb`, amount: Math.max(1, Math.round(mob.attackDamage * effect.absorbMultiplier)), ...(effect.damageSchool ? { school: effect.damageSchool } : {}), }], }), ...(effect.reflectSpellChance === undefined ? {} : { reflectSpellChance: effect.reflectSpellChance }), }, sourceId: id, targetId: id, now, }).auras]; } else if (effect.kind === "target-aura") { const sourceAura = effect.wowAuraSource ? enemySourceAuraContract(effect.wowAuraSource.spellId, effect.wowAuraSource.sourceProfile) : undefined; const sourceDefinition = sourceAura ? supportedEnemySourceAuraDefinition(sourceAura) : undefined; if (effect.wowAuraSource && !sourceDefinition) continue; for (const actorId of effectTargets) { if (deathKnightAntiMagicShellBlocksMagicEffect(namedAuras, actorId, effect.damageSchool ?? attack.school)) continue; if (sourceDefinition) { const debuff = effect.wowAuraSource ? enemyWeaponDebuffContract(effect.wowAuraSource.spellId, effect.wowAuraSource.sourceProfile) : undefined; if (debuff?.applicationGate === "landed-melee-hit") { const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId); const loadout = actorId === PLAYER_AGGRO_ID ? resolvedEquipmentLoadout(runtimeState.equipment, runtimeState.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: runtimeState.classId, secondaryClassId: runtimeState.secondaryClassId, specializationId: runtimeState.specializationId, role: runtimeState.gearRole, level: runtimeState.level, }) : member ? resolvedEquipmentLoadout(runtimeState.equipment, runtimeState.inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, secondaryClassId: member.secondaryClassId, specializationId: member.specializationId, role: member.role, level: member.level, }) : undefined; if (!loadout) continue; const form = wow335DruidShapeshiftFormForAuras(namedAuras, actorId, now); const targetWeapons = enemyDisarmTargetWeaponsFromLoadout(loadout, form === "cat" || form === "bear"); const unavailableChannels = activeEnemyDisarmChannels(namedAuras, actorId, now); if (!enemyDisarmCanApplyToTarget(debuff.profile, { ...targetWeapons, mainHand: targetWeapons.mainHand && !unavailableChannels.has("main-hand"), offHand: targetWeapons.offHand && !unavailableChannels.has("off-hand"), ranged: targetWeapons.ranged && !unavailableChannels.has("ranged"), })) continue; const baseStats = actorId === PLAYER_AGGRO_ID ? playerCharacterStats(runtimeState.classId, runtimeState.level, runtimeState.gearStats, runtimeState.raceId, runtimeState.secondaryClassId, runtimeState.talentModifiers, runtimeState.gender, runtimeState.statRuleset) : member ? deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow") : undefined; const targetAuras = aurasForEntity(namedAuras, actorId, now); if (!baseStats || !resolveEnemyDamageResult(mob, attack, applyAuraStats(baseStats, targetAuras), 0, actorId, attackRevision, now, 0, targetAuras, undefined, 0, true).landed) continue; } namedAuras = debuff ? [...applyCombatAura(namedAuras, { definition: sourceDefinition, sourceId: id, targetId: actorId, now, }).auras] : [...applyEnemySourceAura(namedAuras, sourceDefinition, id, actorId, now)]; continue; } namedAuras = [...applyCombatAura(namedAuras, { definition: sourceDefinition ?? { id: `enemy-debuff:${id}:${attack.id}:${effect.aura}`, name: `${attack.name}: ${effect.aura}`, disposition: "debuff", durationMs: effect.durationMs > 0 ? effect.durationMs : null, dispelCategory: "magic", ...(effect.damageTakenMultiplier === undefined ? {} : { modifiers: [{ kind: "damage" as const, direction: "taken" as const, operation: "percent" as const, value: effect.damageTakenMultiplier - 1, ...(effect.damageSchool ? { school: effect.damageSchool } : {}), }], }), }, sourceId: id, targetId: actorId, now, }).auras]; } } else if (effect.kind === "triggered-shout") { if (!triggeredShout || attack.spellId !== triggeredShout.parentSpellId) continue; const victimId = resolvedEnemyCast?.targetActorId ?? resolution.targetActorId; const victimPosition = actorPosition(runtimeState, victimId); // The parent requires its original explicit victim at cast release. // Area selection is independently caster-centered and has no LOS gate. if (!eligibleActors.includes(victimId) || !victimPosition || !combatHasLineOfSight(position, victimPosition) || Math.hypot(position[0] - victimPosition[0], position[1] - victimPosition[1], position[2] - victimPosition[2]) > triggeredShout.targeting.childVictim.rangeYards) continue; let areaRecipients = eligibleActors.filter((actorId) => { const candidate = actorPosition(runtimeState, actorId); return candidate && Math.hypot(position[0] - candidate[0], position[1] - candidate[1], position[2] - candidate[2]) <= triggeredShout.targeting.parentArea.radiusYards; }); const maximum = triggeredShout.targeting.parentArea.maximumTargets; if (maximum > 0 && areaRecipients.length > maximum) { areaRecipients = areaRecipients.sort((left, right) => ( stableCombatUnit(`${id}:${attack.id}:${attackRevision}:shout-area:${left}`) - stableCombatUnit(`${id}:${attack.id}:${attackRevision}:shout-area:${right}`) || left.localeCompare(right) )).slice(0, maximum); } const plan = planEnemyTriggeredShoutTargets(triggeredShout, { explicitVictimId: victimId, eligibleExplicitVictimIds: [victimId], areaRecipientIds: areaRecipients, }); const applyShout = (actorId: string, spellId: number, mechanic: "fear" | "stun", durationMs: number, physicalDamageTakenPercent = 0) => { if (deathKnightAntiMagicShellBlocksMagicEffect(namedAuras, actorId, attack.school)) return; // Every pinned parent/child has spell-level MECHANIC_FEAR, even // the child whose individual control effect is a stun. Whole-spell // immunity rejects its riders before effect-level stun immunity. if (hasControlImmunity(namedAuras, actorId, "fear")) return; const speed = spellId === triggeredShout.parentSpellId ? enemyTriggeredShoutMovementSpeedEffect(triggeredShout) : undefined; const controlAllowed = !hasControlImmunity(namedAuras, actorId, mechanic); if (!controlAllowed && physicalDamageTakenPercent === 0 && !speed) return; const auraTargetId = actorId === PLAYER_AGGRO_ID ? PLAYER_AURA_ENTITY_ID : actorId; const hasMetamorphosis = aurasForEntity(namedAuras, auraTargetId, now).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG) )); const adjusted = wow335PaladinAuraMasterySelfControlDuration(namedAuras, auraTargetId, mechanic, wow335WarlockMetamorphosisControlDuration(durationMs, mechanic, hasMetamorphosis), now); const owner = actorId === PLAYER_AGGRO_ID ? get() : usePartyStore.getState().members.find((member) => member.id === actorId); const controlDuration = owner ? resolveCoaGuardianControlDurationMs(adjusted, { id: auraTargetId, classId: owner.classId, talentRanks: owner.talentRanks, health: owner.health, }, namedAuras, mechanic, now) : adjusted; namedAuras = [...applyEnemySourceAura(namedAuras, { id: `enemy-control:source-spell-${spellId}`, name: `${attack.name}: ${mechanic}`, disposition: "debuff", durationMs: controlAllowed ? controlDuration : durationMs, stackBehavior: "refresh", // Source rows have Dispel=0. Keep child and parent independently stored. utilityTags: [ ...(controlAllowed ? [enemyTriggeredShoutControlTag(mechanic)] : []), ...(speed ? [speed.utilityTag] : []), ], modifiers: [ ...(physicalDamageTakenPercent ? [{ kind: "damage" as const, direction: "taken" as const, school: "physical" as const, operation: "percent" as const, value: physicalDamageTakenPercent }] : []), ...(speed ? [speed.modifier] : []), ], }, id, actorId, now)]; }; // Core effect slot 0 triggers the child before slots 1/2 apply the area aura. for (const actorId of plan.childTargetIds) { const control = triggeredShout.childEffects.find((entry) => entry.kind === "control"); const damageTaken = triggeredShout.childEffects.find((entry) => entry.kind === "damage-taken"); if (control?.kind === "control") applyShout(actorId, triggeredShout.childSpellId, control.mechanic, control.durationMs, damageTaken?.kind === "damage-taken" ? damageTaken.value : 0); } for (const actorId of plan.parentAreaTargetIds) { applyShout(actorId, triggeredShout.parentSpellId, "fear", triggeredShout.parentSignature.durationMs); } for (const actorId of new Set([...plan.childTargetIds, ...plan.parentAreaTargetIds])) { const control = resolveEnemyTriggeredShoutControl(namedAuras, actorId, now); if (!control) continue; if (actorId === PLAYER_AGGRO_ID) { if (!playerControl || control.endsAt >= playerControl.endsAt) playerControl = control; } else memberControls.push({ memberId: actorId, ...control }); } } else if (effect.kind === "control") { if ((effect.damageMultiplier ?? 0) > 0) { hasDamageResolution = true; const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier!)); const attackDamage = attack.runewakerFormula ? adaptRunewakerEnemyDamage({ formula: attack.runewakerFormula, baselineDamage: mob.attackDamage, }).amount : enemyAttackDamageAfterVindication( mob, attack, baselineDamage, effect.damageMultiplier!, now, namedAuras, ); for (const actorId of targets) { incomingHits.push({ actorId, amount: attackDamage, mob, attack, attackRevision, instantCast: instantSourceCast, }); } } for (const actorId of effectTargets) { if (deathKnightAntiMagicShellBlocksMagicEffect(namedAuras, actorId, attack.school)) continue; if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue; const auraTargetId = actorId === PLAYER_AGGRO_ID ? PLAYER_AURA_ENTITY_ID : actorId; const hasMetamorphosis = aurasForEntity(namedAuras, auraTargetId).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )); const metamorphosisAdjustedDuration = wow335WarlockMetamorphosisControlDuration( effect.durationMs, effect.mechanic, hasMetamorphosis, ); const auraMasteryDurationMs = wow335PaladinAuraMasterySelfControlDuration( namedAuras, auraTargetId, effect.mechanic, metamorphosisAdjustedDuration, now, ); const controlOwner = actorId === PLAYER_AGGRO_ID ? get() : usePartyStore.getState().members.find((member) => member.id === actorId); const controlDurationMs = controlOwner ? resolveCoaGuardianControlDurationMs(auraMasteryDurationMs, { id: auraTargetId, classId: controlOwner.classId, talentRanks: controlOwner.talentRanks, health: controlOwner.health, }, namedAuras, effect.mechanic, now) : auraMasteryDurationMs; const control = { mechanic: effect.mechanic, endsAt: now + controlDurationMs }; if (actorId === PLAYER_AGGRO_ID) { if (!playerControl || control.endsAt >= playerControl.endsAt) playerControl = control; } else memberControls.push({ memberId: actorId, ...control }); namedAuras = [...applyCombatAura(namedAuras, { definition: { id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`, name: `${attack.name}: ${effect.mechanic}`, description: effect.mechanic === "slow" ? `${attack.name} reduces movement speed until the effect ends.` : effect.mechanic === "root" ? `${attack.name} prevents movement until the effect ends.` : `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`, disposition: "debuff", durationMs: controlDurationMs, dispelCategory: "magic", }, sourceId: id, targetId: actorId, now, }).auras]; } } else if (effect.kind === "transform") { activeAuras = [ ...activeAuras.filter((aura) => !aura.id.startsWith("form:")), { id: `form:${effect.form}`, sourceAbilityId: attack.id, endsAt: 0, }, ]; } else if (effect.kind === "pull") { const pulledPosition: CombatPosition = [position[0], position[1], position[2]]; for (const actorId of effectTargets) { if (actorId === PLAYER_AGGRO_ID) { set({ playerPosition: pulledPosition }); } else { updatePartyRuntimePosition( actorId, pulledPosition[0], pulledPosition[1], pulledPosition[2], ); } } } else if (effect.kind === "call-for-help") { for (const helperId of nearbyMobRuntimeIds(position, effect.radius)) { const helper = mobs[helperId]; if (!helper || helper.dead) continue; mobs[helperId] = { ...helper, engaged: true, combatPhase: "chasing", threatByActor: seedActorThreat(helper.threatByActor, resolution.targetActorId), targetActorId: helper.targetActorId ?? resolution.targetActorId, homePosition: helper.homePosition ?? getMobPosition(helperId), abilityReadyAt: Object.keys(helper.abilityReadyAt).length ? helper.abilityReadyAt : initialEnemyAbilityReadyAt(helper, now), }; } } else if (effect.kind === "summon") { const castNumber = (abilityCastCounts[attack.id] ?? 0) + 1; for (let summonIndex = 0; summonIndex < effect.count; summonIndex += 1) { const summonNamespace = effect.entityId?.trim() || `${id}:${attack.id}:summon`; const summonId = `${summonNamespace}:${castNumber}:${summonIndex + 1}`; if (mobs[summonId]) continue; const angle = summonIndex * 2.399963229728653; const summonPosition: CombatPosition = [ position[0] + Math.cos(angle) * 2.2, position[1], position[2] + Math.sin(angle) * 2.2, ]; setMobPosition(summonId, summonPosition); mobs[summonId] = summonedMobCombatState( summonId, mob, effect.creatureEntry, resolution.targetActorId, summonPosition, effect.spawnGroupId, ); } } else if (effect.kind === "encounter-event") { dispatchDungeonEncounterSignal(effect.eventId, now); } } if (hasDamageResolution && targets.length) { const lifeStealPercent = activeAuras.reduce( (maximum, aura) => Math.max(maximum, aura.lifeStealPercent ?? 0), 0, ); if (lifeStealPercent > 0) { enemyHealing.push({ targetId: id, amount: Math.max(1, Math.round(mob.attackDamage * lifeStealPercent)), }); } } if (!hasDamageResolution) { const auraResolution = effects.some((entry) => ( entry.kind === "aura" || entry.kind === "target-aura" || entry.kind === "control" || entry.kind === "triggered-shout" || entry.kind === "transform" )); emitEnemyAttackPresentation( mob, attack, auraResolution ? "aura-start" : "impact", now, position, presentationTargetId, presentationTargetPosition, instantSourceCast, ); } attackRevision += 1; abilityCastCounts[attack.id] = (abilityCastCounts[attack.id] ?? 0) + 1; attackAnimation = attack.animation; nextAttackIndex = (selectedAttackIndex + 1) % mob.attacks.length; lastAttackId = attack.id; const slowAttack = activeStatuses.find((status) => status.kind === "attack-speed")?.magnitude ?? 0; const cooldownRange = attack.repeatCooldownMs ?? [attack.cooldownMs, attack.cooldownMs] as const; abilityReadyAt[attack.id] = attack.maximumCasts && abilityCastCounts[attack.id] >= attack.maximumCasts ? Number.POSITIVE_INFINITY : now + Math.round( deterministicEnemyCooldown(id, attack.id, attackRevision, cooldownRange) * (1 + slowAttack) * (isEnemyMainHandAutoAttack(attack) ? meleeAttackSpeedMultiplier : 1), ); nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY); } if (enemyActiveCast) nextAttackAt = enemyActiveCast.completesAt; if (dualWieldEnabled && nextEnemyOffhandAttackAt > 0) { nextAttackAt = Math.min(nextAttackAt, nextEnemyOffhandAttackAt); } const samePendingExtraAttacks = pendingExtraAttacks.length === (mob.pendingExtraAttacks?.length ?? 0) && pendingExtraAttacks.every((entry, index) => { const previous = mob.pendingExtraAttacks?.[index]; return previous?.id === entry.id && previous.sourceSpellId === entry.sourceSpellId && previous.targetActorId === entry.targetActorId && previous.queuedAt === entry.queuedAt && previous.remaining === entry.remaining; }); if ( phase !== mob.combatPhase || nextAttackAt !== mob.nextAttackAt || meleeAttackSpeedMultiplier !== (mob.meleeAttackSpeedMultiplier ?? 1) || attackRevision !== mob.attackRevision || attackAnimation !== mob.attackAnimation || nextAttackIndex !== mob.nextAttackIndex || lastAttackId !== mob.lastAttackId || dualWieldEnabled !== (mob.dualWieldEnabled ?? false) || nextEnemyOffhandAttackAt !== (mob.nextEnemyOffhandAttackAt ?? 0) || !samePendingExtraAttacks || lastDamagedActorId !== (mob.lastDamagedActorId ?? null) || activeStatuses.length !== mob.statuses.length || activeAuras.length !== mob.auras.length || Object.entries(abilityReadyAt).some(([abilityId, readyAt]) => ( mob.abilityReadyAt[abilityId] !== readyAt )) || Object.entries(abilityCastCounts).some(([abilityId, count]) => ( mob.abilityCastCounts[abilityId] !== count )) || enemyActiveCast !== mob.activeCast || Object.keys(schoolLockedUntil).length !== Object.keys(mob.schoolLockedUntil).length || Object.entries(schoolLockedUntil).some(([school, lockedUntil]) => ( mob.schoolLockedUntil[school as EnemyDamageSchool] !== lockedUntil )) || threatByActor !== mob.threatByActor || resolution.targetActorId !== mob.targetActorId || resolution.forcedTarget !== mob.forcedTarget ) { mobs[id] = { ...mob, combatPhase: phase, nextAttackAt, abilityReadyAt, meleeAttackSpeedMultiplier, abilityCastCounts, activeCast: enemyActiveCast, schoolLockedUntil, attackRevision, attackAnimation, nextAttackIndex, lastAttackId, statuses: activeStatuses, auras: activeAuras, threatByActor, targetActorId: resolution.targetActorId, forcedTarget: resolution.forcedTarget, homePosition: home, dualWieldEnabled, nextEnemyOffhandAttackAt, pendingExtraAttacks, lastDamagedActorId, }; changed = true; } } for (const healing of enemyHealing) { const target = mobs[healing.targetId]; if (!target || target.dead) continue; mobs[healing.targetId] = { ...target, health: Math.min(target.maxHealth, target.health + healing.amount), }; changed = true; } if (changed || playerControl || state.enemyPeriodicEffects.length || enemyPeriodicEffects.length) { set({ mobs, enemyPeriodicEffects, auras: namedAuras, ...(playerControl ? { ...(state.activeCast ? romPriestChampionRageControlAwardPatch(state, state.activeCast, now) : {}), controlledUntil: Math.max(state.controlledUntil, playerControl.endsAt), controlMechanic: playerControl.mechanic, schoolLockedUntil: state.activeCast && (playerControl.mechanic === "silence" || playerControl.mechanic === "interrupt") ? { ...state.schoolLockedUntil, [state.activeCast.school]: Math.max(state.schoolLockedUntil[state.activeCast.school] ?? 0, playerControl.endsAt), } : state.schoolLockedUntil, activeCast: null, } : {}), }); } let appliedDamage = 0; for (const hit of incomingHits) { if (hit.actorId === PLAYER_AURA_ENTITY_ID && state.classId === "rom-warden" && activeWardenNatureCrystal(get().summons, now)) continue; const impactOrigin = getMobPosition(hit.mob.id); const impactTarget = actorPosition(get(), hit.actorId); if (impactOrigin && impactTarget) { emitEnemyAttackPresentation( hit.mob, hit.attack, "impact", now, impactOrigin, hit.actorId, impactTarget, hit.instantCast, ); } const defendingAuras = aurasForEntity(get().auras, hit.actorId); const reflectChance = !hit.attack.periodicTick && enemyAttackKind(hit.attack) === "spell" ? Math.min(1, defendingAuras.reduce((chance, aura) => Math.max(chance, aura.definition.reflectSpellChance ?? 0), 0)) : 0; if (reflectChance > 0 && stableCombatUnit(`${hit.actorId}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:reflect`) < reflectChance) { get().damageMob( hit.mob.id, hit.amount, now, playerThreatSource(get(), `reflect:${hit.attack.id}`), hit.attack.school, ); continue; } const procMob = get().mobs[hit.mob.id] ?? hit.mob; const meleeCriticalCharge = enemyMeleeCriticalCharge(procMob, hit.attack, now); const bonusCriticalChancePercent = meleeCriticalCharge?.nextMeleeCriticalChancePercent ?? 0; const commitMeleeAutoProc = (landed: boolean): void => { const current = get(); const currentMob = current.mobs[hit.mob.id]; if (!currentMob) return; const auras = advanceEnemyMeleeAutoProcAuras( currentMob, hit.attack, now, hit.attackRevision, landed, meleeCriticalCharge?.id, ); if (auras === currentMob.auras) return; set({ mobs: { ...current.mobs, [currentMob.id]: { ...currentMob, auras }, }, }); }; if (hit.actorId === PLAYER_AGGRO_ID) { const current = get(); const playerImpactAuras = aurasForEntity(synchronizedDeterrenceAuras(current, current.auras, now), PLAYER_AURA_ENTITY_ID); const learnedCoaPassives = learnedRomCoaPassives( current.classId, current.secondaryClassId, current.level, current.talentRanks, ); const blockingStanceAuraActive = activeLocalBlockingStance(playerImpactAuras, PLAYER_AURA_ENTITY_ID, now); const coaBlockingModifiers = romCurrentCoaBlockingStanceModifiers({ classId: current.classId as AbilityDefinition["classId"], ...(current.secondaryClassId ? { secondaryClassId: current.secondaryClassId as AbilityDefinition["secondaryClassId"] } : {}), learnedPassiveSpellIds: learnedCoaPassives, blockingStanceAuraActive, }); const baseDefender = applyAuraStats( playerCharacterStats( current.classId, current.level, current.gearStats, current.raceId, current.secondaryClassId, current.talentModifiers, current.gender, current.statRuleset, ), playerImpactAuras, ); const defender = paladinRedoubtBlockStats( { ...baseDefender, parryPercent: baseDefender.parryPercent + coaBlockingModifiers.parryRateBonusPercent, }, current.classId, current.talentRanks, paladinShieldEquipped(current), ); const predatoryInstinctsIncomingMultiplier = druidPredatoryInstinctsIncomingMultiplier( current.classId, current.talentRanks, current.auras, PLAYER_AURA_ENTITY_ID, hit.attack, now, ); const resolvedCombatResult = resolveEnemyDamageResult( hit.mob, hit.attack, defender, talentAdjustedIncomingDamage( Math.round(hit.amount * raceAffixMobDamageMultiplier(hit.mob.id, now) * predatoryInstinctsIncomingMultiplier), current.talentModifiers, hit.attack.school, ), hit.actorId, hit.attackRevision, now, bonusCriticalChancePercent, playerImpactAuras, hit.nativeSourceDirect, hit.targetOrdinal, false, actorIsStunned(state, hit.actorId, now), ); const combatResult = { ...resolvedCombatResult, finalDamage: enemyDamageAfterTargetAuras( resolvedCombatResult.finalDamage, current.auras, hit.mob.id, hit.attack, now, ), }; combatResult.finalDamage = romCoaIncomingDamageAfterPassives({ amount: combatResult.finalDamage, classId: current.classId, secondaryClassId: current.secondaryClassId, level: current.level, talentRanks: current.talentRanks, auras: current.auras, actorId: PLAYER_AURA_ENTITY_ID, school: hit.attack.school, now, }); if (current.classId === "warrior") { combatResult.finalDamage = wow335WarriorCriticalBlockDamage( combatResult.finalDamage, current.talentRanks[WOW335_WARRIOR_CRITICAL_BLOCK_TALENT_ID] ?? 0, resolvedCombatResult.blocked, Boolean(hit.mob.characterDefender), current.talentRanks[WOW335_PALADIN_TOUCHED_BY_THE_LIGHT_TALENT_ID] ?? 0, ); } commitMeleeAutoProc(combatResult.landed); const blessedLifeDamage = paladinBlessedLifeDamageAfterIncomingHit( combatResult.finalDamage, current.classId, current.talentRanks, combatResult.landed, hit.actorId, hit.mob.id, hit.attack.id, hit.attackRevision, now, ); const priestMitigated = priestTestOfFaithIncomingDamage( blessedLifeDamage, current.classId, current.talentRanks, current.activeCast, hit.attack.school, now, ); const playerAuras = aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID); const defensiveStanceActive = current.classId === "warrior" && wow335WarriorHasDefensiveStance(playerAuras, PLAYER_AURA_ENTITY_ID, now); const damageTransferPet = activeWardenDamageTransferPet( current.summons, PLAYER_AGGRO_ID, now, current.playerPosition, ); const damageTransferActive = Boolean(damageTransferPet && playerAuras.some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(ROM_WARDEN_DAMAGE_TRANSFER_TAG) ))); const damageTransferAuras = playerAuras.filter((aura) => ( !aura.definition.utilityTags?.includes(ROM_WARDEN_DAMAGE_TRANSFER_TAG) )); const damageTransferContext = { direction: "taken" as const, school: hit.attack.school, attackKind: enemyAttackKind(hit.attack), }; const damageBeforeTransferBase = damageTransferActive ? resolveAuraDamageValue(priestMitigated, damageTransferAuras, damageTransferContext) : 0; const damageBeforeTransfer = damageTransferActive ? wow335WarriorImprovedDefensiveStanceAdditiveIncomingDamage( priestMitigated, damageBeforeTransferBase, current.talentRanks[WOW335_WARRIOR_IMPROVED_DEFENSIVE_STANCE_TALENT_ID] ?? 0, defensiveStanceActive, ) : 0; const auraAdjustedDamageBase = resolveAuraDamageValue( priestMitigated, damageTransferActive ? playerAuras : damageTransferAuras, damageTransferContext, ); const auraAdjustedDamage = wow335WarriorImprovedDefensiveStanceAdditiveIncomingDamage( priestMitigated, auraAdjustedDamageBase, current.talentRanks[WOW335_WARRIOR_IMPROVED_DEFENSIVE_STANCE_TALENT_ID] ?? 0, defensiveStanceActive, ); const spellDeflectionRank = current.classId === "death-knight" ? current.talentRanks[WOW335_DEATH_KNIGHT_SPELL_DEFLECTION_TALENT_ID] ?? 0 : 0; const spellDeflectionProc = combatResult.landed && wow335DeathKnightSpellDeflectionShouldProc( spellDeflectionRank, { directDamageSpell: !hit.attack.periodicTick && enemyAttackKind(hit.attack) === "spell", schoolMask: wow335DeathKnightSpellDeflectionSchoolMask(hit.attack.school), parryChancePercent: defender.parryPercent, roll: stableCombatUnit( `${hit.actorId}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:wow335-death-knight-spell-deflection`, ), damage: auraAdjustedDamage, }, ); const spellDeflectionDamage = spellDeflectionProc ? wow335DeathKnightSpellDeflectionDamage(auraAdjustedDamage, spellDeflectionRank) : auraAdjustedDamage; const spellDeflectionAbsorbed = Math.max(0, auraAdjustedDamage - spellDeflectionDamage); const frostWarding = combatResult.landed ? mageFrostWardingIncomingReduction({ classId: current.classId, talentRank: current.talentRanks[WOW335_MAGE_FROST_WARDING_TALENT_ID] ?? 0, auras: current.auras, targetId: PLAYER_AURA_ENTITY_ID, attackKind: enemyAttackKind(hit.attack), damageSchool: hit.attack.school, spellDamage: spellDeflectionDamage, roll: stableCombatUnit(`${hit.actorId}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:wow335-frost-warding`), now, }) : null; const soulLinkDamage = resolveWarlockSoulLinkDamage( frostWarding?.damageAfterReduction ?? spellDeflectionDamage, current.classId, current.talentRanks, current.auras, current.summons, PLAYER_AURA_ENTITY_ID, current.playerPosition, now, ); const damageTransferredToPet = damageTransferActive ? Math.round(damageBeforeTransfer * ROM_WARDEN_DAMAGE_TRANSFER_FRACTION) : 0; const namedAbsorb = absorbAuraDamage(current.auras, { targetId: PLAYER_AURA_ENTITY_ID, amount: soulLinkDamage.casterDamage, school: hit.attack.school, now, countsAsAttack: true, }); const absorbed = Math.min(current.shield, namedAbsorb.remainingDamage); const healthDamage = Math.min(hit.attack.periodicNoKill ? Math.max(0, current.health - 1) : current.health, namedAbsorb.remainingDamage - absorbed); const petAbsorb = damageTransferPet && damageTransferredToPet > 0 ? absorbAuraDamage(namedAbsorb.auras, { targetId: damageTransferPet.id, amount: damageTransferredToPet, school: hit.attack.school, now, countsAsAttack: true, }) : null; const petHealthDamage = damageTransferPet && petAbsorb ? Math.min(damageTransferPet.health, Math.max(0, Math.round(petAbsorb.remainingDamage))) : 0; const wardenUpdatedSummons = damageTransferPet && petAbsorb && petHealthDamage > 0 ? current.summons.flatMap((summon) => summon.id !== damageTransferPet.id ? [summon] : summon.health - petHealthDamage > 0 ? [{ ...summon, health: summon.health - petHealthDamage }] : []) : current.summons; const nextSummons = soulLinkDamage.demon && soulLinkDamage.demonDamage > 0 ? wardenUpdatedSummons.flatMap((summon) => summon.id !== soulLinkDamage.demon!.id ? [summon] : summon.health - soulLinkDamage.demonDamage > 0 ? [{ ...summon, health: summon.health - soulLinkDamage.demonDamage }] : []) : wardenUpdatedSummons; const health = current.health - healthDamage; const rage = gainRomRage( current.classId, current.secondaryClassId, healthDamage > 0 && ( hasRomWarriorRagePool(current.classId, current.secondaryClassId) || (!hasRomRagePool(current.classId, current.secondaryClassId) && current.maxResourcePools.rage !== undefined) ) ? RAGE_PER_HIT_TAKEN : 0, current.resource, current.maxResource, { ...current.resourcePools }, { ...current.maxResourcePools }, ); const deathKnightResource = grantDeathKnightAntiMagicShellRunicPower( current.classId, rage.resource, rage.resourcePools, current.maxResource, current.maxResourcePools, deathKnightAntiMagicShellRunicPowerGain( current.auras, namedAbsorb.consumptions, PLAYER_AURA_ENTITY_ID, hit.attack.school, now, ), ); const avoidTrigger = combatResult.dodged ? "dodge" : combatResult.parried ? "parry" : combatResult.blocked ? "block" : null; const procEvent: ProcEvent | null = avoidTrigger ? { trigger: avoidTrigger, subjectId: PLAYER_AURA_ENTITY_ID, otherId: hit.mob.id, abilityId: hit.attack.id, ...(avoidTrigger === "block" ? { amount: combatResult.blockedAmount } : {}) } : healthDamage > 0 || (combatResult.landed && soulLinkDamage.casterDamage > 0) ? { trigger: "damage-taken", subjectId: PLAYER_AURA_ENTITY_ID, otherId: hit.mob.id, abilityId: hit.attack.id, amount: Math.max(healthDamage, soulLinkDamage.casterDamage), school: hit.attack.school, attackKind: enemyAttackKind(hit.attack), } : null; const procProgression = progressionFromState(current); procProgression.health = health; const procWork: MutableCastWork = { classId: current.classId, statRuleset: current.statRuleset, gender: current.gender, abilities: current.abilities, mobs: { ...get().mobs }, effects: accumulateLightwellDamageCancellation( current.effects, PLAYER_AURA_ENTITY_ID, healthDamage, ), auras: [...(petAbsorb?.auras ?? namedAbsorb.auras)], summons: [...nextSummons], areaEffects: [...current.areaEffects], combatEvents: [...current.combatEvents], actors: { ...current.actors }, cooldowns: { ...current.cooldowns }, health, maxHealth: current.maxHealth, shield: current.shield - absorbed, shieldExpiresAt: current.shieldExpiresAt, resource: deathKnightResource.resource, maxResource: current.maxResource, resourcePools: { ...deathKnightResource.resourcePools }, maxResourcePools: { ...current.maxResourcePools }, lastResourceSpentAt: { ...current.lastResourceSpentAt }, comboPoints: current.comboPoints, comboTargetId: current.comboTargetId, deathKnightRunes: current.deathKnightRunes, progression: procProgression, totalDamage: 0, totalHealing: 0, combatResults: [], talentRanks: current.talentRanks, talentModifiers: current.talentModifiers, gearStats: current.gearStats, raceId: current.raceId, secondaryClassId: current.secondaryClassId, }; const revivalStateLearned = current.abilities.some((ability) => ( ability.source === "runewaker" && ability.dbcSpellId === 499544 && isAbilityUnlocked(ability, current.level) )); if (romWardenRevivalShouldTriggerOnIncomingHit({ classId: current.classId, secondaryClassId: current.secondaryClassId, stateActive: revivalStateLearned && romWardenRevivalStateIsActive( current.auras, PLAYER_AURA_ENTITY_ID, now, ), normal: combatResult.landed && !combatResult.critical, healthDamage, healthAfter: health, })) { procWork.auras = applyRomWardenRevivalOnHit(procWork.auras, PLAYER_AURA_ENTITY_ID, now); } const panthersBlessingRestore = romKnightPanthersBlessingRestoreOnDodge({ classId: current.classId, secondaryClassId: current.secondaryClassId, learnedPassiveSpellIds: learnedCoaPassives, dodged: combatResult.dodged, ownerAlive: procWork.health > 0, maximumHealth: procWork.maxHealth, currentHealth: procWork.health, maximumMana: procWork.maxResourcePools.mana ?? 0, currentMana: procWork.resourcePools.mana ?? 0, }); if (panthersBlessingRestore) { procWork.health += panthersBlessingRestore.restoredHealth; procProgression.health = procWork.health; procWork.totalHealing += panthersBlessingRestore.restoredHealth; const manaBefore = procWork.resourcePools.mana ?? 0; const manaAfter = manaBefore + panthersBlessingRestore.restoredMana; if (panthersBlessingRestore.requestedMana > 0) { procWork.resourcePools.mana = manaAfter; if (resourceProfileForClass(procWork.classId).type === "mana") procWork.resource = manaAfter; } const playerActor = procWork.actors[PLAYER_AURA_ENTITY_ID]; if (playerActor) { const maximumMana = procWork.maxResourcePools.mana ?? 0; const manaPool = playerActor.resources.mana ?? (maximumMana > 0 ? normalizeActorResourcePool("mana", manaBefore, maximumMana) : undefined); procWork.actors[PLAYER_AURA_ENTITY_ID] = { ...playerActor, health: procWork.health, alive: procWork.health > 0, ...(manaPool ? { resources: { ...playerActor.resources, mana: { ...manaPool, current: manaAfter }, }, } : {}), }; } if (panthersBlessingRestore.requestedHealth > 0) recordCombatEventInWork(procWork, { occurredAt: now, kind: "healing", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ROM_KNIGHT_PANTHERS_BLESSING_ABILITY_ID, rawAmount: panthersBlessingRestore.requestedHealth, effectiveAmount: panthersBlessingRestore.restoredHealth, overheal: panthersBlessingRestore.requestedHealth - panthersBlessingRestore.restoredHealth, absorbed: 0, critical: false, outcome: panthersBlessingRestore.restoredHealth > 0 ? "hit" : "overheal", }); if (panthersBlessingRestore.requestedMana > 0) recordCombatEventInWork(procWork, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AURA_ENTITY_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ROM_KNIGHT_PANTHERS_BLESSING_ABILITY_ID, rawAmount: panthersBlessingRestore.requestedMana, effectiveAmount: panthersBlessingRestore.restoredMana, overheal: panthersBlessingRestore.requestedMana - panthersBlessingRestore.restoredMana, absorbed: 0, critical: false, }); } if (current.classId === "shaman") { procWork.auras = [...wow335ShamanNaturesGuardianApplyProc( procWork.auras, PLAYER_AURA_ENTITY_ID, current.talentRanks[WOW335_SHAMAN_NATURES_GUARDIAN_TALENT_ID] ?? 0, { attackKind: enemyAttackKind(hit.attack), directDamage: !hit.attack.periodicTick, landed: combatResult.landed, healthDamage, healthBefore: current.health, healthAfter: current.health - healthDamage, maxHealth: current.maxHealth, roll: stableCombatUnit( `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${hit.actorId}:wow335-shaman-natures-guardian`, ), }, now, )]; } if (frostWarding && frostWarding.manaRefund > 0) { const maximumMana = procWork.maxResourcePools.mana ?? defaultResourceMaximum("mana"); const currentMana = procWork.resourcePools.mana ?? 0; const restoredMana = Math.min(frostWarding.manaRefund, Math.max(0, maximumMana - currentMana)); if (restoredMana > 0) { procWork.resourcePools.mana = currentMana + restoredMana; if (resourceProfileForClass(procWork.classId).type === "mana") procWork.resource = currentMana + restoredMana; recordCombatEventInWork(procWork, { occurredAt: now, kind: "resource", sourceActorId: PLAYER_AGGRO_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: frostWarding.wardAbilityId, rawAmount: frostWarding.manaRefund, effectiveAmount: restoredMana, overheal: frostWarding.manaRefund - restoredMana, absorbed: 0, critical: false, }); } } if (soulLinkDamage.demon && soulLinkDamage.demonDamage > 0) { const updatedDemon = nextSummons.find((summon) => summon.id === soulLinkDamage.demon!.id); if (updatedDemon) procWork.actors[updatedDemon.id] = combatActorFromSummon( updatedDemon, current.level, procWork.auras, ); else delete procWork.actors[soulLinkDamage.demon.id]; } if (healthDamage > 0) { procWork.auras = procWork.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("stealth") )); } const scentOfBloodTrigger = combatResult.dodged ? "dodge" : combatResult.parried ? "parry" : combatResult.landed && combatResult.rawDamage > 0 ? "damage-taken" : null; if (current.classId === "death-knight" && scentOfBloodTrigger) { const scentEventId = `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${hit.actorId}`; const scentOfBlood = wow335DeathKnightScentOfBloodOnIncomingEvent( procWork.auras, PLAYER_AURA_ENTITY_ID, current.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0, scentOfBloodTrigger, stableCombatUnit(`${scentEventId}:wow335-death-knight-scent-of-blood`), now, scentEventId, ); procWork.auras = [...scentOfBlood.auras]; } if (current.classId === "rom-rogue" && current.secondaryClassId === "rom-mage" && avoidTrigger === "dodge") { const instantShadow = procWork.abilities.find((ability) => ( ability.source === "runewaker" && ability.dbcSpellId === 497206 )); if (instantShadow && (procWork.cooldowns[instantShadow.id] ?? 0) <= now) { procWork.auras = [...applyCombatAura(procWork.auras, { definition: { id: "rom-rogue:instant-shadow-ready", name: "Instant Shadow ready", disposition: "buff", // RuneWaker defines the dodge prerequisite but no timeout for using the ready state. durationMs: null, maxStacks: 1, stackBehavior: "refresh", utilityTags: [ROM_ROGUE_INSTANT_SHADOW_READY_TAG], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } } if (current.classId === "rogue" && avoidTrigger === "parry") { procWork.auras = [...applyCombatAura(procWork.auras, { definition: { id: "wow335:rogue:riposte-ready", name: "Riposte ready", disposition: "buff", durationMs: null, maxStacks: 1, stackBehavior: "refresh", utilityTags: ["wow335-rogue-riposte-ready"], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } const unfairAdvantageRank = Math.max(0, current.talentRanks["talent-2073"] ?? 0); const unfairAdvantageMultiplier = wow335RogueUnfairAdvantageDamageMultiplierForRank( unfairAdvantageRank, ); const unfairAdvantageOnCooldown = procWork.auras.some((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(WOW335_ROGUE_UNFAIR_ADVANTAGE_COOLDOWN_TAG) )); if (current.classId === "rogue" && unfairAdvantageMultiplier > 0 && (avoidTrigger === "dodge" || avoidTrigger === "parry") && !unfairAdvantageOnCooldown) { procWork.auras = [...applyCombatAura(procWork.auras, { definition: { id: WOW335_ROGUE_UNFAIR_ADVANTAGE_COOLDOWN_TAG, name: "Unfair Advantage cooldown", disposition: "buff", durationMs: 2_000, maxStacks: 1, stackBehavior: "refresh", utilityTags: [WOW335_ROGUE_UNFAIR_ADVANTAGE_COOLDOWN_TAG], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; const mainHandDamage = playerAutoAttackProfile(current, procWork.auras, undefined, now).damage; damageMobInWork( procWork.classId, procWork, hit.mob.id, mainHandDamage * unfairAdvantageMultiplier, now, playerThreatSource( { talentModifiers: procWork.talentModifiers }, WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID, ), "physical", ); } if (current.classId === "hunter" && avoidTrigger === "parry") { procWork.auras = [...applyCombatAura(procWork.auras, { definition: { id: "wow335:hunter:counterattack-ready", name: "Counterattack ready", disposition: "buff", durationMs: null, maxStacks: 1, stackBehavior: "refresh", utilityTags: ["wow335-hunter-counterattack-ready"], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } if (current.classId === "warrior" && avoidTrigger) { procWork.auras = maybeProcWarriorImprovedDefensiveStance( procWork.auras, PLAYER_AURA_ENTITY_ID, current.talentRanks[WOW335_WARRIOR_IMPROVED_DEFENSIVE_STANCE_TALENT_ID] ?? 0, avoidTrigger, `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${hit.actorId}`, now, ); } if (current.classId === "warrior" && avoidTrigger === "parry") { procWork.auras = maybeProcWarriorImprovedVictoryRushOnParry( procWork.auras, PLAYER_AURA_ENTITY_ID, current.talentRanks[WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_TALENT_ID] ?? 0, true, `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${hit.actorId}`, now, ); } const focusedWillRank = current.talentRanks[WOW335_PRIEST_FOCUSED_WILL_TALENT_ID] ?? 0; const focusedWillAura = current.classId === "priest" ? wow335PriestFocusedWillAuraDefinition(focusedWillRank) : null; if (focusedWillAura && wow335PriestFocusedWillShouldProc( focusedWillRank, combatResult.landed, combatResult.critical, )) { procWork.auras = [...applyCombatAura(procWork.auras, { definition: focusedWillAura, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }).auras]; } const shieldOfValorCooldownReduction = romKnightShieldOfValorCooldownReductionMs( procWork.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(ROM_KNIGHT_SHIELD_OF_VALOR_TAG)), combatResult.parried, ); if (current.classId === "rom-knight" && shieldOfValorCooldownReduction > 0) { const holySeal = current.abilities.find((ability) => ability.source === "runewaker" && ability.dbcSpellId === 490149); if (holySeal) { const readyAt = procWork.cooldowns[holySeal.id] ?? now; procWork.cooldowns[holySeal.id] = Math.max(now, readyAt - shieldOfValorCooldownReduction); } } procWork.auras = consumeInnerFireCharges( procWork.auras, PLAYER_AURA_ENTITY_ID, combatResult.landed, enemyAttackKind(hit.attack), ); if (procEvent) evaluateProcsInWork(procWork, procEvent, now, (sourceActorId) => ( paladinAuraSourceSpellPower(sourceActorId, current, procWork.auras, now) )); procWork.auras = [...applyCoaGuardianVanguardEvent(procWork.auras, { id: PLAYER_AURA_ENTITY_ID, classId: current.classId, talentRanks: current.talentRanks, }, combatResult.blocked ? "block" : combatResult.landed && !hit.attack.periodicTick && enemyAttackKind(hit.attack) === "spell" ? "spell-hit" : "other", now)]; const paragon = applyCoaGuardianParagonEvent(procWork.auras, { id: PLAYER_AURA_ENTITY_ID, classId: current.classId, talentRanks: current.talentRanks, }, { type: combatResult.blocked ? "block" : "other" }, now); procWork.auras = [...paragon.auras]; if (paragon.braceCooldownReductionMs > 0) { const brace = procWork.abilities.find((ability) => ability.talentEntryId === 30057 && ability.classId === "guardian"); if (brace && (procWork.cooldowns[brace.id] ?? 0) > now) { procWork.cooldowns[brace.id] = Math.max(now, procWork.cooldowns[brace.id] - paragon.braceCooldownReductionMs); } } const incomingAttackKind = enemyAttackKind(hit.attack); const briarShieldActive = procWork.auras.some((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(ROM_WARDEN_BRIAR_SHIELD_TAG) )); const briarShieldInRange = impactOrigin && impactTarget && distanceSquared(impactOrigin, impactTarget) <= 50 * 50; const hasWardenClass = current.classId === "rom-warden" || current.secondaryClassId === "rom-warden"; if (hasWardenClass && romWardenBriarShieldShouldReflect({ auraActive: briarShieldActive, landed: combatResult.landed, school: hit.attack.school, attackKind: incomingAttackKind, withinRange: Boolean(briarShieldInRange), })) { const reflectedAmount = romWardenBriarShieldReflectDamage( current.level, current.gearStats.meleeWeapon, ); if (reflectedAmount > 0 && procWork.mobs[hit.mob.id]) { const briarShield = procWork.abilities.find((ability) => ability.dbcSpellId === 493642); damageMobInWork( procWork.classId, procWork, hit.mob.id, reflectedAmount, now, playerThreatSource( { talentModifiers: procWork.talentModifiers }, briarShield?.id ?? "rom-warden-general-1", true, ), "physical", ); } } procWork.auras = maybeApplyWarlockBacklash( procWork.auras, current.classId, current.talentRanks, enemyAttackKind(hit.attack), hit.attack.school, combatResult.landed, `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${now}`, now, ); maybeProcPaladinReckoningInWork( procWork, enemyAttackKind(hit.attack), combatResult.landed || avoidTrigger !== null, `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${now}`, now, ); maybeProcPaladinRedoubtInWork( procWork, combatResult.blocked, paladinShieldEquipped(current), `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${now}`, now, ); maybeProcPaladinEyeForAnEyeInWork(procWork, hit.mob.id, healthDamage, combatResult.critical, now); const warriorDamageShield = current.classId === "warrior" ? wow335WarriorDamageShieldReflectDamage( defender.blockValue, current.talentRanks[WOW335_WARRIOR_DAMAGE_SHIELD_TALENT_ID] ?? 0, enemyAttackKind(hit.attack) === "melee" && combatResult.landed && combatResult.rawDamage > 0, ) : 0; if (warriorDamageShield > 0 && procWork.mobs[hit.mob.id]) { damageMobInWork( procWork.classId, procWork, hit.mob.id, warriorDamageShield, now, playerThreatSource( { talentModifiers: procWork.talentModifiers }, WOW335_WARRIOR_DAMAGE_SHIELD_ABILITY_ID, ), "physical", ); } recordCombatEventInWork(procWork, { occurredAt: now, kind: "damage", sourceActorId: hit.mob.id, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: hit.attack.id, school: hit.attack.school, ...enemyWeaponEventDetails(hit.mob, hit.attack), rawAmount: combatResult.rawDamage, effectiveAmount: healthDamage, overheal: 0, absorbed: spellDeflectionAbsorbed + namedAbsorb.absorbed + absorbed, critical: combatResult.critical, outcome: combatResult.outcome, }); maybeProcWarriorBloodCrazeInWork(procWork, combatResult.critical, now); if (combatResult.landed) { const livingSeed = consumeDruidLivingSeed(procWork.auras, PLAYER_AURA_ENTITY_ID, now); procWork.auras = livingSeed.auras; if (livingSeed.healingAmount > 0) { const healed = healPlayerInWork(procWork, livingSeed.healingAmount); procWork.totalHealing += healed; recordCombatEventInWork(procWork, { occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: DRUID_LIVING_SEED_ABILITY_ID, school: "nature", rawAmount: livingSeed.healingAmount, effectiveAmount: healed, overheal: livingSeed.healingAmount - healed, absorbed: 0, critical: false, outcome: healed > 0 ? "hit" : "overheal", }); } const markOfBlood = wow335DeathKnightMarkOfBloodOnDamage( procWork.auras, hit.mob.id, health, procWork.maxHealth, now, combatResult.rawDamage > 0, ); procWork.auras = [...markOfBlood.auras]; if (markOfBlood.triggered && markOfBlood.healingAmount > 0) { const healed = healPlayerInWork(procWork, markOfBlood.healingAmount); procWork.totalHealing += healed; recordCombatEventInWork(procWork, { occurredAt: now, kind: "healing", sourceActorId: markOfBlood.sourceActorId ?? PLAYER_AGGRO_ID, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: markOfBlood.abilityId ?? WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, rawAmount: markOfBlood.healingAmount, effectiveAmount: healed, overheal: markOfBlood.healingAmount - healed, absorbed: 0, critical: false, outcome: healed > 0 ? "hit" : "overheal", }); } } if (damageTransferPet && petAbsorb && damageTransferredToPet > 0) { recordCombatEventInWork(procWork, { occurredAt: now, kind: "damage", sourceActorId: hit.mob.id, targetActorId: damageTransferPet.id, abilityId: hit.attack.id, school: hit.attack.school, rawAmount: damageTransferredToPet, effectiveAmount: petHealthDamage, overheal: 0, absorbed: petAbsorb.absorbed, critical: combatResult.critical, outcome: petHealthDamage > 0 ? "hit" : "absorb", }); } if (soulLinkDamage.demon && soulLinkDamage.demonDamage > 0) { recordCombatEventInWork(procWork, { occurredAt: now, kind: "damage", sourceActorId: hit.mob.id, targetActorId: soulLinkDamage.demon.id, abilityId: "wow335-warlock-soul-link", school: hit.attack.school, rawAmount: soulLinkDamage.demonDamage, effectiveAmount: soulLinkDamage.demonDamage, overheal: 0, absorbed: 0, critical: combatResult.critical, outcome: "hit", }); } procWork.auras = [...updateRomLeopardInstinctStacks( procWork.auras, PLAYER_AURA_ENTITY_ID, learnedCoaPassives.includes(ROM_COA_LEOPARD_INSTINCT_ID), combatResult.dodged ? "dodge" : combatResult.landed && combatResult.rawDamage > 0 ? "hit" : "other", now, )]; if (procWork.health <= 0) { procWork.auras = procWork.auras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG))); delete procWork.cooldowns[ROM_SCOUT_BLOOD_ARROW_ABILITY_ID]; } const playerPushbackReduction = healthDamage > 0 ? paladinConcentrationAuraPushbackReductionForTarget( procWork.auras, PLAYER_AURA_ENTITY_ID, now, current.classId, current.talentRanks, usePartyStore.getState().members, ) : 0; set({ auras: procWork.auras, effects: procWork.effects, summons: procWork.summons, mobs: procWork.mobs, shield: procWork.shield, health: procWork.health, resource: procWork.resource, resourcePools: procWork.resourcePools, cooldowns: procWork.cooldowns, combatEvents: procWork.combatEvents, actors: procWork.actors, lastCombatResult: combatResult, activeCast: procWork.health <= 0 ? null : healthDamage > 0 ? applyWrathCastPushback( current.activeCast, now, `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}`, playerPushbackReduction, ) : current.activeCast, autoAttackTargetId: procWork.health <= 0 ? null : current.autoAttackTargetId, nextAutoAttackAt: procWork.health <= 0 ? 0 : current.nextAutoAttackAt, nextOffhandAutoAttackAt: procWork.health <= 0 ? 0 : current.nextOffhandAutoAttackAt, autoShotTargetId: procWork.health <= 0 ? null : current.autoShotTargetId, nextAutoShotAt: procWork.health <= 0 ? 0 : current.nextAutoShotAt, playerAnimationEvent: healthDamage > 0 ? nextAnimationEvent(procWork.health <= 0 ? "death" : "wound") : current.playerAnimationEvent, feedback: nextFeedback( "damage", procWork.health <= 0 ? "You have been defeated." : `${hit.mob.name}'s ${hit.attack.name} ${combatResultDescription(combatResult)}.`, { amount: healthDamage, abilityId: hit.attack.id }, ), }); appliedDamage += healthDamage; } else { const partyState = usePartyStore.getState(); const summon = get().summons.find((candidate) => candidate.id === hit.actorId); if (summon) { const current = get(); const critical = bonusCriticalChancePercent > 0; const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1)); const wardenAdjustedAmount = enemyDamageAfterTargetAuras( incomingAmount, current.auras, hit.mob.id, hit.attack, now, ); commitMeleeAutoProc(true); const hunterPetDefender = hunterPetIncomingDefender(current, summon, now); const damageAfterHunterPetDefense = hunterPetDefender ? resolveCombatResult({ amount: wardenAdjustedAmount, attackKind: enemyAttackKind(hit.attack), damageSchool: hit.attack.school, attacker: { level: hit.mob.level }, defender: hunterPetDefender, rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false, }, roll: stableCombatRoll( `${hit.mob.id}:${hit.attack.id}:${summon.id}:${hit.attackRevision}:${now}:wow335-hunter-pet-defense`, ), }).finalDamage : wardenAdjustedAmount; const adjusted = resolveAuraDamageValue( damageAfterHunterPetDefense, aurasForEntity(current.auras, summon.id), { direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) }, ); const absorbed = absorbAuraDamage(current.auras, { targetId: summon.id, amount: adjusted, school: hit.attack.school, now, countsAsAttack: true, }); const applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage))); const livingSeed = consumeDruidLivingSeed(absorbed.auras, summon.id, now); const healthAfterDamage = summon.health - applied; const livingSeedHealing = healthAfterDamage > 0 ? Math.min(livingSeed.healingAmount, summon.maxHealth - healthAfterDamage) : 0; const healthAfterLivingSeed = healthAfterDamage + livingSeedHealing; const markOfBlood = wow335DeathKnightMarkOfBloodOnDamage( livingSeed.auras, hit.mob.id, healthAfterDamage, summon.maxHealth, now, incomingAmount > 0, ); const markOfBloodHealing = healthAfterLivingSeed > 0 ? Math.min(markOfBlood.healingAmount, summon.maxHealth - healthAfterLivingSeed) : 0; const healthAfterMarkOfBlood = healthAfterLivingSeed + markOfBloodHealing; const aurasAfterProwlBreak = incomingAmount > 0 && summon.hunterPet?.prowl?.breaksOnIncomingDamage ? markOfBlood.auras.filter((aura) => !( aura.targetId === summon.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_PROWL_TAG) )) : markOfBlood.auras; let combatEvents = recordCombatEvent(current.combatEvents, { occurredAt: now, kind: "damage", sourceActorId: hit.mob.id, targetActorId: summon.id, abilityId: hit.attack.id, school: hit.attack.school, ...enemyWeaponEventDetails(hit.mob, hit.attack), rawAmount: incomingAmount, effectiveAmount: applied, overheal: 0, absorbed: absorbed.absorbed, critical, outcome: applied > 0 ? (critical ? "crit" : "hit") : "absorb", }); if (livingSeed.healingAmount > 0) combatEvents = recordCombatEvent(combatEvents, { occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID, targetActorId: summon.id, abilityId: DRUID_LIVING_SEED_ABILITY_ID, school: "nature", rawAmount: livingSeed.healingAmount, effectiveAmount: livingSeedHealing, overheal: livingSeed.healingAmount - livingSeedHealing, absorbed: 0, critical: false, outcome: livingSeedHealing > 0 ? "hit" : "overheal", }); if (markOfBlood.triggered && markOfBlood.healingAmount > 0) combatEvents = recordCombatEvent(combatEvents, { occurredAt: now, kind: "healing", sourceActorId: markOfBlood.sourceActorId ?? PLAYER_AGGRO_ID, targetActorId: summon.id, abilityId: markOfBlood.abilityId ?? WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, rawAmount: markOfBlood.healingAmount, effectiveAmount: markOfBloodHealing, overheal: markOfBlood.healingAmount - markOfBloodHealing, absorbed: 0, critical: false, outcome: markOfBloodHealing > 0 ? "hit" : "overheal", }); set({ auras: aurasAfterProwlBreak, summons: current.summons.flatMap((candidate) => candidate.id !== summon.id ? [candidate] : healthAfterMarkOfBlood > 0 ? [{ ...candidate, health: healthAfterMarkOfBlood }] : []), combatEvents, }); appliedDamage += applied; continue; } const onlineActor = onlinePlayerRuntimeActor(hit.actorId); if (onlineActor) { const current = get(); const critical = bonusCriticalChancePercent > 0; const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1)); const wardenAdjustedAmount = enemyDamageAfterTargetAuras( incomingAmount, current.auras, hit.mob.id, hit.attack, now, ); commitMeleeAutoProc(true); const adjusted = resolveAuraDamageValue( wardenAdjustedAmount, aurasForEntity(current.auras, onlineActor.id), { direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) }, ); const absorbed = absorbAuraDamage(current.auras, { targetId: onlineActor.id, amount: adjusted, school: hit.attack.school, now, countsAsAttack: true, }); const damaged = Math.min(onlineActor.health, Math.max(0, Math.round(absorbed.remainingDamage))); let nextActor: CombatActorState = { ...onlineActor, health: onlineActor.health - damaged, alive: onlineActor.health - damaged > 0, cast: onlineActor.health - damaged > 0 ? onlineActor.cast : null, }; const livingSeed = consumeDruidLivingSeed(absorbed.auras, onlineActor.id, now); const livingSeedHealing = nextActor.health > 0 ? Math.min(livingSeed.healingAmount, nextActor.maxHealth - nextActor.health) : 0; if (livingSeedHealing > 0) nextActor = { ...nextActor, health: nextActor.health + livingSeedHealing, alive: true, }; const markOfBlood = wow335DeathKnightMarkOfBloodOnDamage( livingSeed.auras, hit.mob.id, onlineActor.health - damaged, nextActor.maxHealth, now, incomingAmount > 0, ); const markOfBloodHealing = nextActor.health > 0 ? Math.min(markOfBlood.healingAmount, nextActor.maxHealth - nextActor.health) : 0; if (markOfBloodHealing > 0) nextActor = { ...nextActor, health: nextActor.health + markOfBloodHealing, alive: true, }; let combatEvents = recordCombatEvent(current.combatEvents, { occurredAt: now, kind: "damage", sourceActorId: hit.mob.id, targetActorId: onlineActor.id, abilityId: hit.attack.id, school: hit.attack.school, ...enemyWeaponEventDetails(hit.mob, hit.attack), rawAmount: incomingAmount, effectiveAmount: damaged, overheal: 0, absorbed: absorbed.absorbed, critical, outcome: damaged > 0 ? (critical ? "crit" : "hit") : "absorb", }); if (livingSeed.healingAmount > 0) combatEvents = recordCombatEvent(combatEvents, { occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID, targetActorId: onlineActor.id, abilityId: DRUID_LIVING_SEED_ABILITY_ID, school: "nature", rawAmount: livingSeed.healingAmount, effectiveAmount: livingSeedHealing, overheal: livingSeed.healingAmount - livingSeedHealing, absorbed: 0, critical: false, outcome: livingSeedHealing > 0 ? "hit" : "overheal", }); if (markOfBlood.triggered && markOfBlood.healingAmount > 0) combatEvents = recordCombatEvent(combatEvents, { occurredAt: now, kind: "healing", sourceActorId: markOfBlood.sourceActorId ?? PLAYER_AGGRO_ID, targetActorId: onlineActor.id, abilityId: markOfBlood.abilityId ?? WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, rawAmount: markOfBlood.healingAmount, effectiveAmount: markOfBloodHealing, overheal: markOfBlood.healingAmount - markOfBloodHealing, absorbed: 0, critical: false, outcome: markOfBloodHealing > 0 ? "hit" : "overheal", }); set({ actors: { ...current.actors, [nextActor.id]: nextActor }, auras: markOfBlood.auras, combatEvents, }); appliedDamage += damaged; continue; } const storedMember = partyState.members.find((candidate) => candidate.id === hit.actorId); if (!storedMember) continue; const currentBeforeHit = get(); const authoritativeActor = companionActorFromMember( storedMember, entityAbsorbAmount(currentBeforeHit.auras, storedMember.id), currentBeforeHit.actors[storedMember.id], ); const member = memberFromCompanionActor(storedMember, authoritativeActor); if (member.health <= 0) continue; const memberImpactAuras = aurasForEntity(synchronizedDeterrenceAuras(currentBeforeHit, currentBeforeHit.auras, now), member.id); const learnedMemberCoaPassives = learnedRomCoaPassives( member.classId, member.secondaryClassId, member.level, member.talentRanks, ); const memberBlockingStanceActive = activeLocalBlockingStance(memberImpactAuras, member.id, now); const memberBlockingModifiers = romCurrentCoaBlockingStanceModifiers({ classId: member.classId as AbilityDefinition["classId"], ...(member.secondaryClassId ? { secondaryClassId: member.secondaryClassId as AbilityDefinition["secondaryClassId"] } : {}), learnedPassiveSpellIds: learnedMemberCoaPassives, blockingStanceAuraActive: memberBlockingStanceActive, }); const baseMemberDefender = applyAuraStats( deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", ), memberImpactAuras, ); const defender = { ...baseMemberDefender, parryPercent: baseMemberDefender.parryPercent + memberBlockingModifiers.parryRateBonusPercent, }; const predatoryInstinctsIncomingMultiplier = druidPredatoryInstinctsIncomingMultiplier( member.classId, member.talentRanks, currentBeforeHit.auras, member.id, hit.attack, now, ); const resolvedCombatResult = resolveEnemyDamageResult( hit.mob, hit.attack, defender, talentAdjustedIncomingDamage( Math.round(hit.amount * raceAffixMobDamageMultiplier(hit.mob.id, now) * predatoryInstinctsIncomingMultiplier), member.classId === "shaman" ? talentModifiersForRanks(member.talentRanks, member.classId).filter((modifier) => ( modifier.sourceTalentId === WOW335_SHAMAN_ELEMENTAL_WARDING_TALENT_ID )) : [], hit.attack.school, ), hit.actorId, hit.attackRevision, now, bonusCriticalChancePercent, memberImpactAuras, hit.nativeSourceDirect, hit.targetOrdinal, false, actorIsStunned(state, hit.actorId, now), ); const combatResult = { ...resolvedCombatResult, finalDamage: enemyDamageAfterTargetAuras( resolvedCombatResult.finalDamage, get().auras, hit.mob.id, hit.attack, now, ), }; combatResult.finalDamage = romCoaIncomingDamageAfterPassives({ amount: combatResult.finalDamage, classId: member.classId, secondaryClassId: member.secondaryClassId, level: member.level, talentRanks: member.talentRanks, auras: currentBeforeHit.auras, actorId: member.id, school: hit.attack.school, now, }); if (member.classId === "warrior") { combatResult.finalDamage = wow335WarriorCriticalBlockDamage( combatResult.finalDamage, member.talentRanks[WOW335_WARRIOR_CRITICAL_BLOCK_TALENT_ID] ?? 0, resolvedCombatResult.blocked, Boolean(hit.mob.characterDefender), member.talentRanks[WOW335_PALADIN_TOUCHED_BY_THE_LIGHT_TALENT_ID] ?? 0, ); } commitMeleeAutoProc(combatResult.landed); const currentAuras = get().auras; const memberAuras = aurasForEntity(currentAuras, hit.actorId); const damageTransferPet = activeWardenDamageTransferPet( currentBeforeHit.summons, member.id, now, getPartyRuntimePosition(member.id), ); const damageTransferActive = Boolean(damageTransferPet && memberAuras.some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(ROM_WARDEN_DAMAGE_TRANSFER_TAG) ))); const damageTransferAuras = memberAuras.filter((aura) => ( !aura.definition.utilityTags?.includes(ROM_WARDEN_DAMAGE_TRANSFER_TAG) )); const damageBeforeAuras = paladinBlessedLifeDamageAfterIncomingHit( combatResult.finalDamage, member.classId, member.talentRanks, combatResult.landed, hit.actorId, hit.mob.id, hit.attack.id, hit.attackRevision, now, ); const damageTransferContext = { direction: "taken" as const, school: hit.attack.school, attackKind: enemyAttackKind(hit.attack), }; const damageBeforeTransferBase = damageTransferActive ? resolveAuraDamageValue(damageBeforeAuras, damageTransferAuras, damageTransferContext) : 0; const damageBeforeTransfer = damageTransferActive ? wow335WarriorImprovedDefensiveStanceAdditiveIncomingDamage( damageBeforeAuras, damageBeforeTransferBase, member.talentRanks[WOW335_WARRIOR_IMPROVED_DEFENSIVE_STANCE_TALENT_ID] ?? 0, member.classId === "warrior" && wow335WarriorHasDefensiveStance(memberAuras, hit.actorId, now), ) : 0; const auraAdjustedDamageBase = resolveAuraDamageValue( damageBeforeAuras, damageTransferActive ? memberAuras : damageTransferAuras, damageTransferContext, ); const auraAdjustedDamage = wow335WarriorImprovedDefensiveStanceAdditiveIncomingDamage( damageBeforeAuras, auraAdjustedDamageBase, member.talentRanks[WOW335_WARRIOR_IMPROVED_DEFENSIVE_STANCE_TALENT_ID] ?? 0, member.classId === "warrior" && wow335WarriorHasDefensiveStance(memberAuras, hit.actorId, now), ); const spellDeflectionRank = member.classId === "death-knight" ? member.talentRanks[WOW335_DEATH_KNIGHT_SPELL_DEFLECTION_TALENT_ID] ?? 0 : 0; const spellDeflectionProc = combatResult.landed && wow335DeathKnightSpellDeflectionShouldProc( spellDeflectionRank, { directDamageSpell: !hit.attack.periodicTick && enemyAttackKind(hit.attack) === "spell", schoolMask: wow335DeathKnightSpellDeflectionSchoolMask(hit.attack.school), parryChancePercent: defender.parryPercent, roll: stableCombatUnit( `${hit.actorId}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:wow335-death-knight-spell-deflection`, ), damage: auraAdjustedDamage, }, ); const spellDeflectionDamage = spellDeflectionProc ? wow335DeathKnightSpellDeflectionDamage(auraAdjustedDamage, spellDeflectionRank) : auraAdjustedDamage; const spellDeflectionAbsorbed = Math.max(0, auraAdjustedDamage - spellDeflectionDamage); const frostWarding = combatResult.landed ? mageFrostWardingIncomingReduction({ classId: member.classId, talentRank: member.talentRanks[WOW335_MAGE_FROST_WARDING_TALENT_ID] ?? 0, auras: currentAuras, targetId: member.id, attackKind: enemyAttackKind(hit.attack), damageSchool: hit.attack.school, spellDamage: spellDeflectionDamage, roll: stableCombatUnit(`${member.id}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:wow335-frost-warding`), now, }) : null; const soulLinkDamage = resolveWarlockSoulLinkDamage( frostWarding?.damageAfterReduction ?? spellDeflectionDamage, member.classId, member.talentRanks, currentAuras, currentBeforeHit.summons, member.id, actorPosition(currentBeforeHit, member.id), now, ); const damageTransferredToPet = damageTransferActive ? Math.round(damageBeforeTransfer * ROM_WARDEN_DAMAGE_TRANSFER_FRACTION) : 0; const namedAbsorb = absorbAuraDamage(currentAuras, { targetId: hit.actorId, amount: soulLinkDamage.casterDamage, school: hit.attack.school, now, countsAsAttack: true, }); const antiMagicShellRunicPowerGain = deathKnightAntiMagicShellRunicPowerGain( currentAuras, namedAbsorb.consumptions, hit.actorId, hit.attack.school, now, ); const existingRunicPower = authoritativeActor.resources["runic-power"]; const resourcesAfterAntiMagicShell = member.classId === "death-knight" && antiMagicShellRunicPowerGain > 0 ? { ...authoritativeActor.resources, "runic-power": normalizeActorResourcePool( "runic-power", (existingRunicPower?.current ?? member.resourcePools["runic-power"] ?? 0) + antiMagicShellRunicPowerGain, existingRunicPower?.maximum ?? (member.resourceType === "runic-power" ? member.maxResource : 100), existingRunicPower?.regenerationPerSecond ?? 0, existingRunicPower?.lastSpentAt ?? 0, ), } : authoritativeActor.resources; const existingManaPool = resourcesAfterAntiMagicShell.mana ?? authoritativeActor.resources.mana; const frostWardingManaRefund = frostWarding?.manaRefund ?? 0; const currentMana = existingManaPool?.current ?? member.resourcePools.mana ?? 0; const maximumMana = existingManaPool?.maximum ?? (member.resourceType === "mana" ? member.maxResource : defaultResourceMaximum("mana")); const restoredMana = Math.min(frostWardingManaRefund, Math.max(0, maximumMana - currentMana)); const resourcesAfterFrostWarding = restoredMana > 0 ? { ...resourcesAfterAntiMagicShell, mana: normalizeActorResourcePool( "mana", currentMana + restoredMana, maximumMana, existingManaPool?.regenerationPerSecond ?? 0, existingManaPool?.lastSpentAt ?? 0, ), } : resourcesAfterAntiMagicShell; const redirectAura = aurasForEntity(namedAbsorb.auras, hit.actorId) .find((aura) => (aura.definition.redirectDamagePercent ?? 0) > 0); const redirectFraction = Math.min(1, Math.max(0, redirectAura?.definition.redirectDamagePercent ?? 0)); const proposedRedirect = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID ? Math.round(namedAbsorb.remainingDamage * redirectFraction) : 0; const paladinSacrifice = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID && redirectAura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice") ? namedAbsorb.auras.find((aura) => aura.sourceId === PLAYER_AURA_ENTITY_ID && aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")) : undefined; const redirectCap = paladinSacrifice ? Math.round(get().maxHealth * Math.max(0, paladinSacrifice.definition.redirectDamageCapPercent ?? 0)) : Number.POSITIVE_INFINITY; const redirected = Math.min( proposedRedirect, Math.max(0, redirectCap - (paladinSacrifice?.redirectedDamage ?? 0)), ); let aurasAfterRedirect = namedAbsorb.auras; if (paladinSacrifice && redirected > 0) { aurasAfterRedirect = aurasAfterRedirect.map((aura) => aura.instanceId === paladinSacrifice.instanceId ? { ...aura, redirectedDamage: (aura.redirectedDamage ?? 0) + redirected } : aura); } if (redirected > 0 && redirectAura?.definition.utilityTags?.includes("rom-disregard-danger-one-hit")) { aurasAfterRedirect = aurasAfterRedirect.filter((aura) => aura.instanceId !== redirectAura.instanceId); } const petAbsorb = damageTransferPet && damageTransferredToPet > 0 ? absorbAuraDamage(aurasAfterRedirect, { targetId: damageTransferPet.id, amount: damageTransferredToPet, school: hit.attack.school, now, countsAsAttack: true, }) : null; const petHealthDamage = damageTransferPet && petAbsorb ? Math.min(damageTransferPet.health, Math.max(0, Math.round(petAbsorb.remainingDamage))) : 0; const wardenUpdatedSummons = damageTransferPet && petAbsorb && petHealthDamage > 0 ? currentBeforeHit.summons.flatMap((summon) => summon.id !== damageTransferPet.id ? [summon] : summon.health - petHealthDamage > 0 ? [{ ...summon, health: summon.health - petHealthDamage }] : []) : currentBeforeHit.summons; const damaged = Math.min( hit.attack.periodicNoKill ? Math.max(0, authoritativeActor.health - 1) : authoritativeActor.health, Math.max(0, Math.round(namedAbsorb.remainingDamage - redirected)), ); const nextSummons = soulLinkDamage.demon && soulLinkDamage.demonDamage > 0 ? wardenUpdatedSummons.flatMap((summon) => summon.id !== soulLinkDamage.demon!.id ? [summon] : summon.health - soulLinkDamage.demonDamage > 0 ? [{ ...summon, health: summon.health - soulLinkDamage.demonDamage }] : []) : wardenUpdatedSummons; let nextCast = authoritativeActor.cast; if (damaged > 0 && nextCast && nextCast.pushbackHits < 2) { const castingAbility = abilityById(nextCast.abilityId); const resistance = castingAbility ? abilityPushbackResistance(castingAbility) : 0; if (stableCombatUnit(`${member.id}:${hit.attack.id}:${hit.attackRevision}:pushback`) >= resistance) { const pushbackReduction = paladinConcentrationAuraPushbackReductionForTarget( aurasAfterRedirect, member.id, now, currentBeforeHit.classId, currentBeforeHit.talentRanks, partyState.members, ); const remainingPenalty = 1 - pushbackReduction; nextCast = { ...nextCast, pushbackHits: nextCast.pushbackHits + 1, completesAt: nextCast.mode === "channel" ? Math.max(now, nextCast.completesAt - nextCast.originalDurationMs * 0.25 * remainingPenalty) : nextCast.completesAt + Math.round(500 * remainingPenalty), }; } } const ragePool = authoritativeActor.resources.rage; let nextActor: CombatActorState = { ...authoritativeActor, health: authoritativeActor.health - damaged, alive: authoritativeActor.health - damaged > 0, cast: authoritativeActor.health - damaged > 0 ? nextCast : null, resources: damaged > 0 && ragePool ? { ...resourcesAfterFrostWarding, rage: { ...ragePool, current: Math.min(ragePool.maximum, ragePool.current + RAGE_PER_HIT_TAKEN), }, } : resourcesAfterFrostWarding, }; const partyShieldOfValorCooldownReduction = romKnightShieldOfValorCooldownReductionMs( member.classId === "rom-knight" && aurasAfterRedirect.some((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(ROM_KNIGHT_SHIELD_OF_VALOR_TAG)), combatResult.parried, ); if (partyShieldOfValorCooldownReduction > 0) { const holySeal = abilitiesForCharacter(member.classId, member.secondaryClassId).find((ability) => ( ability.source === "runewaker" && ability.dbcSpellId === 490149 )); if (holySeal) { const readyAt = nextActor.cooldowns[holySeal.id] ?? now; nextActor = { ...nextActor, cooldowns: { ...nextActor.cooldowns, [holySeal.id]: Math.max(now, readyAt - partyShieldOfValorCooldownReduction), }, }; } } const unfairAdvantagePartyMultiplier = member.classId === "rogue" ? wow335RogueUnfairAdvantageDamageMultiplierForRank(member.talentRanks["talent-2073"] ?? 0) : 0; const unfairAdvantagePartyShouldProc = unfairAdvantagePartyMultiplier > 0 && member.gearStats.meleeWeapon !== null && (combatResult.dodged || combatResult.parried) && (authoritativeActor.cooldowns[WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID] ?? 0) <= now; const unfairAdvantagePartyDamage = unfairAdvantagePartyShouldProc ? playerAutoAttackProfile({ ...member, secondaryClassId: member.secondaryClassId ?? null, }, aurasForEntity(currentAuras, member.id), undefined, now).damage * unfairAdvantagePartyMultiplier : 0; if (unfairAdvantagePartyShouldProc) { nextActor = { ...nextActor, cooldowns: { ...nextActor.cooldowns, [WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID]: now + 2_000, }, }; } if (redirected > 0) { const player = get(); const transferred = Math.min(player.health, redirected); set({ health: player.health - transferred, effects: accumulateLightwellDamageCancellation(player.effects, PLAYER_AURA_ENTITY_ID, transferred), activeCast: player.health - transferred <= 0 ? null : player.activeCast, }); appliedDamage += transferred; } appliedDamage += damaged; const procState = get(); let aurasAfterHit = consumeInnerFireCharges( petAbsorb?.auras ?? aurasAfterRedirect, member.id, combatResult.landed, enemyAttackKind(hit.attack), ); const memberRevivalStateLearned = romWardenRevivalBuildMatches( member.classId, member.secondaryClassId, ) && abilitiesForCharacter(member.classId, member.secondaryClassId).some((ability) => ( ability.source === "runewaker" && ability.dbcSpellId === 499544 && learnedAbilityForBuild(ability, member.level, member.talentRanks) !== null )); if (romWardenRevivalShouldTriggerOnIncomingHit({ classId: member.classId, secondaryClassId: member.secondaryClassId, stateActive: memberRevivalStateLearned && romWardenRevivalStateIsActive( currentAuras, member.id, now, ), normal: combatResult.landed && !combatResult.critical, healthDamage: damaged, healthAfter: authoritativeActor.health - damaged, })) { aurasAfterHit = applyRomWardenRevivalOnHit(aurasAfterHit, member.id, now); } if (member.classId === "shaman") { aurasAfterHit = [...wow335ShamanNaturesGuardianApplyProc( aurasAfterHit, member.id, member.talentRanks[WOW335_SHAMAN_NATURES_GUARDIAN_TALENT_ID] ?? 0, { attackKind: enemyAttackKind(hit.attack), directDamage: !hit.attack.periodicTick, landed: combatResult.landed, healthDamage: damaged, healthBefore: authoritativeActor.health, healthAfter: authoritativeActor.health - damaged, maxHealth: authoritativeActor.maxHealth, roll: stableCombatUnit( `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${hit.actorId}:wow335-shaman-natures-guardian`, ), }, now, )]; } const focusedWillRank = member.talentRanks[WOW335_PRIEST_FOCUSED_WILL_TALENT_ID] ?? 0; const focusedWillAura = member.classId === "priest" ? wow335PriestFocusedWillAuraDefinition(focusedWillRank) : null; if (focusedWillAura && wow335PriestFocusedWillShouldProc( focusedWillRank, combatResult.landed, combatResult.critical, )) { aurasAfterHit = [...applyCombatAura(aurasAfterHit, { definition: focusedWillAura, sourceId: member.id, targetId: member.id, now, }).auras]; } const partyScentOfBloodTrigger = combatResult.dodged ? "dodge" : combatResult.parried ? "parry" : combatResult.landed && combatResult.rawDamage > 0 ? "damage-taken" : null; if (member.classId === "death-knight" && partyScentOfBloodTrigger) { const scentEventId = `${member.id}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}`; const scentOfBlood = wow335DeathKnightScentOfBloodOnIncomingEvent( aurasAfterHit, member.id, member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0, partyScentOfBloodTrigger, stableCombatUnit(`${scentEventId}:wow335-death-knight-scent-of-blood`), now, scentEventId, ); aurasAfterHit = [...scentOfBlood.auras]; } const partyAvoidTrigger: Wow335WarriorAvoidanceTrigger | null = combatResult.dodged ? "dodge" : combatResult.parried ? "parry" : combatResult.blocked ? "block" : null; if (member.classId === "warrior" && partyAvoidTrigger) { aurasAfterHit = maybeProcWarriorImprovedDefensiveStance( aurasAfterHit, member.id, member.talentRanks[WOW335_WARRIOR_IMPROVED_DEFENSIVE_STANCE_TALENT_ID] ?? 0, partyAvoidTrigger, `${member.id}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}`, now, ); } if (member.classId === "warrior" && combatResult.parried) { aurasAfterHit = maybeProcWarriorImprovedVictoryRushOnParry( aurasAfterHit, member.id, member.talentRanks[WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_TALENT_ID] ?? 0, true, `${member.id}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}`, now, ); } let actorsAfterHit = { ...procState.actors, [nextActor.id]: nextActor }; if (damageTransferPet && petAbsorb && damageTransferredToPet > 0) { const updatedPet = nextSummons.find((summon) => summon.id === damageTransferPet.id); if (updatedPet) { actorsAfterHit[updatedPet.id] = combatActorFromSummon( updatedPet, currentBeforeHit.actors[updatedPet.id]?.level ?? member.level, petAbsorb.auras, ); } else delete actorsAfterHit[damageTransferPet.id]; } let aurasAfterProcs = aurasAfterHit; let eventsAfterHit = recordCombatEvent(procState.combatEvents, { occurredAt: now, kind: "damage", sourceActorId: hit.mob.id, targetActorId: member.id, abilityId: hit.attack.id, school: hit.attack.school, ...enemyWeaponEventDetails(hit.mob, hit.attack), rawAmount: combatResult.rawDamage, effectiveAmount: damaged, overheal: 0, absorbed: spellDeflectionAbsorbed + namedAbsorb.absorbed, critical: combatResult.critical, outcome: combatResult.outcome, }); if (restoredMana > 0 && frostWarding) eventsAfterHit = recordCombatEvent(eventsAfterHit, { occurredAt: now, kind: "resource", sourceActorId: member.id, targetActorId: member.id, abilityId: frostWarding.wardAbilityId, rawAmount: frostWardingManaRefund, effectiveAmount: restoredMana, overheal: frostWardingManaRefund - restoredMana, absorbed: 0, critical: false, }); if (damageTransferPet && petAbsorb && damageTransferredToPet > 0) { eventsAfterHit = recordCombatEvent(eventsAfterHit, { occurredAt: now, kind: "damage", sourceActorId: hit.mob.id, targetActorId: damageTransferPet.id, abilityId: hit.attack.id, school: hit.attack.school, rawAmount: damageTransferredToPet, effectiveAmount: petHealthDamage, overheal: 0, absorbed: petAbsorb.absorbed, critical: combatResult.critical, outcome: petHealthDamage > 0 ? "hit" : "absorb", }); } if (soulLinkDamage.demon && soulLinkDamage.demonDamage > 0) { eventsAfterHit = recordCombatEvent(eventsAfterHit, { occurredAt: now, kind: "damage", sourceActorId: hit.mob.id, targetActorId: soulLinkDamage.demon.id, abilityId: "wow335-warlock-soul-link", school: hit.attack.school, rawAmount: soulLinkDamage.demonDamage, effectiveAmount: soulLinkDamage.demonDamage, overheal: 0, absorbed: 0, critical: combatResult.critical, outcome: "hit", }); const updatedDemon = nextSummons.find((summon) => summon.id === soulLinkDamage.demon!.id); if (updatedDemon) actorsAfterHit[updatedDemon.id] = combatActorFromSummon( updatedDemon, currentBeforeHit.actors[updatedDemon.id]?.level ?? member.level, currentAuras, ); else delete actorsAfterHit[soulLinkDamage.demon.id]; } const livingSeed = combatResult.landed ? consumeDruidLivingSeed(aurasAfterHit, member.id, now) : { auras: aurasAfterHit, healingAmount: 0 }; aurasAfterHit = livingSeed.auras; aurasAfterProcs = aurasAfterHit; const livingSeedHealing = nextActor.health > 0 ? Math.min(livingSeed.healingAmount, nextActor.maxHealth - nextActor.health) : 0; if (livingSeedHealing > 0) nextActor = { ...nextActor, health: nextActor.health + livingSeedHealing, alive: true, }; const markOfBlood = wow335DeathKnightMarkOfBloodOnDamage( aurasAfterHit, hit.mob.id, authoritativeActor.health - damaged, nextActor.maxHealth, now, combatResult.landed && combatResult.rawDamage > 0, ); aurasAfterHit = [...markOfBlood.auras]; aurasAfterProcs = aurasAfterHit; const markOfBloodHealing = nextActor.health > 0 ? Math.min(markOfBlood.healingAmount, nextActor.maxHealth - nextActor.health) : 0; if (markOfBloodHealing > 0) nextActor = { ...nextActor, health: nextActor.health + markOfBloodHealing, alive: true, }; const panthersBlessingMana = nextActor.resources.mana; const panthersBlessingRestore = romKnightPanthersBlessingRestoreOnDodge({ classId: member.classId, secondaryClassId: member.secondaryClassId, learnedPassiveSpellIds: learnedMemberCoaPassives, dodged: combatResult.dodged, ownerAlive: nextActor.health > 0, maximumHealth: nextActor.maxHealth, currentHealth: nextActor.health, maximumMana: panthersBlessingMana?.maximum ?? 0, currentMana: panthersBlessingMana?.current ?? 0, }); if (panthersBlessingRestore) { nextActor = { ...nextActor, health: nextActor.health + panthersBlessingRestore.restoredHealth, alive: nextActor.health + panthersBlessingRestore.restoredHealth > 0, ...(panthersBlessingMana && panthersBlessingRestore.requestedMana > 0 ? { resources: { ...nextActor.resources, mana: { ...panthersBlessingMana, current: panthersBlessingMana.current + panthersBlessingRestore.restoredMana, }, }, } : {}), }; } actorsAfterHit = { ...actorsAfterHit, [nextActor.id]: nextActor }; mirrorCompanionActor(nextActor); if (livingSeed.healingAmount > 0) eventsAfterHit = recordCombatEvent(eventsAfterHit, { occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID, targetActorId: member.id, abilityId: DRUID_LIVING_SEED_ABILITY_ID, school: "nature", rawAmount: livingSeed.healingAmount, effectiveAmount: livingSeedHealing, overheal: livingSeed.healingAmount - livingSeedHealing, absorbed: 0, critical: false, outcome: livingSeedHealing > 0 ? "hit" : "overheal", }); if (markOfBlood.triggered && markOfBlood.healingAmount > 0) eventsAfterHit = recordCombatEvent(eventsAfterHit, { occurredAt: now, kind: "healing", sourceActorId: markOfBlood.sourceActorId ?? PLAYER_AGGRO_ID, targetActorId: member.id, abilityId: markOfBlood.abilityId ?? WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, rawAmount: markOfBlood.healingAmount, effectiveAmount: markOfBloodHealing, overheal: markOfBlood.healingAmount - markOfBloodHealing, absorbed: 0, critical: false, outcome: markOfBloodHealing > 0 ? "hit" : "overheal", }); if (panthersBlessingRestore?.requestedHealth) eventsAfterHit = recordCombatEvent(eventsAfterHit, { occurredAt: now, kind: "healing", sourceActorId: member.id, targetActorId: member.id, abilityId: ROM_KNIGHT_PANTHERS_BLESSING_ABILITY_ID, rawAmount: panthersBlessingRestore.requestedHealth, effectiveAmount: panthersBlessingRestore.restoredHealth, overheal: panthersBlessingRestore.requestedHealth - panthersBlessingRestore.restoredHealth, absorbed: 0, critical: false, outcome: panthersBlessingRestore.restoredHealth > 0 ? "hit" : "overheal", }); if (panthersBlessingRestore?.requestedMana) eventsAfterHit = recordCombatEvent(eventsAfterHit, { occurredAt: now, kind: "resource", sourceActorId: member.id, targetActorId: member.id, abilityId: ROM_KNIGHT_PANTHERS_BLESSING_ABILITY_ID, rawAmount: panthersBlessingRestore.requestedMana, effectiveAmount: panthersBlessingRestore.restoredMana, overheal: panthersBlessingRestore.requestedMana - panthersBlessingRestore.restoredMana, absorbed: 0, critical: false, }); let partyRetributionAuraProcs: readonly TriggeredAuraProc[] = []; const hasPartyDamageTakenProcAura = aurasAfterHit.some((aura) => aura.targetId === member.id && (isPrayerOfMendingAura(aura) || aura.definition.procs?.some((proc) => ( proc.id.endsWith(":lightning-shield") || proc.id.endsWith(":earth-shield") )))); if (damaged > 0 && hasPartyDamageTakenProcAura) { const procProgression = progressionFromState(procState); const procWork: MutableCastWork = { classId: procState.classId, statRuleset: procState.statRuleset, gender: procState.gender, abilities: procState.abilities, mobs: { ...procState.mobs }, effects: [...procState.effects], auras: [...aurasAfterHit], summons: [...procState.summons], areaEffects: [...procState.areaEffects], combatEvents: [...eventsAfterHit], actors: actorsAfterHit, cooldowns: { ...procState.cooldowns }, health: procState.health, maxHealth: procState.maxHealth, shield: procState.shield, shieldExpiresAt: procState.shieldExpiresAt, resource: procState.resource, maxResource: procState.maxResource, resourcePools: { ...procState.resourcePools }, maxResourcePools: { ...procState.maxResourcePools }, lastResourceSpentAt: { ...procState.lastResourceSpentAt }, comboPoints: procState.comboPoints, comboTargetId: procState.comboTargetId, deathKnightRunes: procState.deathKnightRunes, progression: procProgression, totalDamage: 0, totalHealing: 0, combatResults: [], talentRanks: procState.talentRanks, talentModifiers: procState.talentModifiers, gearStats: procState.gearStats, raceId: procState.raceId, secondaryClassId: procState.secondaryClassId, }; const damageTakenEvent: ProcEvent = { trigger: "damage-taken", subjectId: member.id, otherId: hit.mob.id, abilityId: hit.attack.id, amount: damaged, school: hit.attack.school, attackKind: enemyAttackKind(hit.attack), }; evaluatePrayerOfMendingInWork(procWork, damageTakenEvent, now); evaluatePartyShamanShieldProcsInWork(procWork, damageTakenEvent, now); actorsAfterHit = procWork.actors; aurasAfterProcs = procWork.auras; eventsAfterHit = procWork.combatEvents; } if (combatResult.landed && combatResult.rawDamage > 0) { const retributionResult = evaluatePartyRetributionAuraInWork(aurasAfterProcs, { trigger: "damage-taken", subjectId: member.id, otherId: hit.mob.id, abilityId: hit.attack.id, amount: damaged, school: hit.attack.school, attackKind: enemyAttackKind(hit.attack), }, now, (sourceActorId) => paladinAuraSourceSpellPower( sourceActorId, procState, aurasAfterProcs, now, )); aurasAfterProcs = retributionResult.auras; partyRetributionAuraProcs = retributionResult.triggered; } aurasAfterProcs = [...updateRomLeopardInstinctStacks( aurasAfterProcs, member.id, learnedMemberCoaPassives.includes(ROM_COA_LEOPARD_INSTINCT_ID), combatResult.dodged ? "dodge" : combatResult.landed && combatResult.rawDamage > 0 ? "hit" : "other", now, )]; aurasAfterProcs = [...applyCoaGuardianVanguardEvent(aurasAfterProcs, { id: member.id, classId: member.classId, talentRanks: member.talentRanks, }, combatResult.blocked ? "block" : combatResult.landed && !hit.attack.periodicTick && enemyAttackKind(hit.attack) === "spell" ? "spell-hit" : "other", now)]; const paragon = applyCoaGuardianParagonEvent(aurasAfterProcs, member, { type: combatResult.blocked ? "block" : "other" }, now); aurasAfterProcs = [...paragon.auras]; if (paragon.braceCooldownReductionMs > 0) { const brace = abilitiesForCharacter(member.classId, member.secondaryClassId) .find((ability) => ability.talentEntryId === 30057 && ability.classId === "guardian"); if (brace && (nextActor.cooldowns[brace.id] ?? 0) > now) { nextActor = { ...nextActor, cooldowns: { ...nextActor.cooldowns, [brace.id]: Math.max(now, nextActor.cooldowns[brace.id] - paragon.braceCooldownReductionMs) } }; actorsAfterHit = { ...actorsAfterHit, [nextActor.id]: nextActor }; mirrorCompanionActor(nextActor); } } if (nextActor.health <= 0) { aurasAfterProcs = aurasAfterProcs.filter((aura) => !(aura.targetId === member.id && aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG))); nextActor = { ...nextActor, cooldowns: Object.fromEntries(Object.entries(nextActor.cooldowns) .filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)), }; actorsAfterHit = { ...actorsAfterHit, [nextActor.id]: nextActor }; mirrorCompanionActor(nextActor); } set({ actors: actorsAfterHit, summons: nextSummons, auras: aurasAfterProcs, effects: accumulateLightwellDamageCancellation(procState.effects, member.id, damaged), combatEvents: eventsAfterHit, health: procState.health, lastCombatResult: combatResult, feedback: nextFeedback( "damage", `${hit.mob.name}'s ${hit.attack.name} ${combatResultDescription(combatResult)} against ${member.name}.`, { amount: damaged, abilityId: hit.attack.id }, ), }); if (member.classId === "warrior") { const warriorDamageShield = wow335WarriorDamageShieldReflectDamage( defender.blockValue, member.talentRanks[WOW335_WARRIOR_DAMAGE_SHIELD_TALENT_ID] ?? 0, enemyAttackKind(hit.attack) === "melee" && combatResult.landed && combatResult.rawDamage > 0, ); if (warriorDamageShield > 0) { const current = useCombatStore.getState(); current.damageMob( hit.mob.id, warriorDamageShield, now, threatSourceForActor(current, member.id, WOW335_WARRIOR_DAMAGE_SHIELD_ABILITY_ID), "physical", ); } } for (const proc of partyRetributionAuraProcs) { if (proc.action.kind !== "damage" || !proc.event.otherId || proc.action.amount <= 0) continue; const current = useCombatStore.getState(); current.damageMob( proc.event.otherId, proc.action.amount, now, threatSourceForActor(current, proc.sourceId, proc.procId, true), proc.action.school, ); } if (unfairAdvantagePartyShouldProc) { useCombatStore.getState().damageMob( hit.mob.id, unfairAdvantagePartyDamage, now, { actorId: member.id, role: member.role, abilityId: WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID, }, "physical", ); } } } for (const control of memberControls) { const current = get(); const summon = current.summons.find((candidate) => candidate.id === control.memberId); if (summon) { const nextSummon = { ...summon, controlledUntil: Math.max(summon.controlledUntil, control.endsAt), cast: null, schoolLockedUntil: control.mechanic === "silence" && summon.cast ? { ...summon.schoolLockedUntil, [summon.cast.school]: Math.max(summon.schoolLockedUntil[summon.cast.school] ?? 0, control.endsAt), } : summon.schoolLockedUntil, }; set({ summons: current.summons.map((candidate) => candidate.id === summon.id ? nextSummon : candidate), actors: { ...current.actors, [summon.id]: combatActorFromSummon(nextSummon, current.level, current.auras), }, }); continue; } const partyState = usePartyStore.getState(); const storedMember = partyState.members.find((member) => member.id === control.memberId); if (!storedMember) { const onlineActor = onlinePlayerRuntimeActor(control.memberId); if (onlineActor) { set({ actors: { ...current.actors, [onlineActor.id]: { ...onlineActor, controlledUntil: Math.max(onlineActor.controlledUntil, control.endsAt), cast: null, }, }, }); } continue; } const actor = companionActorFromMember( storedMember, entityAbsorbAmount(current.auras, storedMember.id), current.actors[storedMember.id], ); const nextActor: CombatActorState = { ...actor, controlledUntil: Math.max(actor.controlledUntil, control.endsAt), cast: null, schoolLockedUntil: control.mechanic === "silence" && actor.cast ? { ...actor.schoolLockedUntil, [actor.cast.school]: Math.max(actor.schoolLockedUntil[actor.cast.school] ?? 0, control.endsAt), } : actor.schoolLockedUntil, }; set({ actors: { ...current.actors, [nextActor.id]: nextActor } }); mirrorCompanionActor(nextActor); partyState.updateMemberCombat(control.memberId, { controlMechanic: control.mechanic, }); } const lastDamagedBySource = new Map(); for (const hit of incomingHits) { if (!supportsEnemyCreatureWeaponAttacks(hit.mob)) continue; const currentTarget = lastDamagedBySource.get(hit.mob.id) ?? null; const nextTarget = trackEnemyLastDamagedTarget(currentTarget, { targetActorId: hit.actorId, isWeaponSwing: isEnemyWeaponSwing(hit.attack), periodicTick: Boolean(hit.attack.periodicTick), sourceDamage: hit.amount, }); if (nextTarget) lastDamagedBySource.set(hit.mob.id, nextTarget); } if (lastDamagedBySource.size > 0) { const currentMobs = get().mobs; const updatedMobs = { ...currentMobs }; let changedTarget = false; for (const [sourceId, targetActorId] of lastDamagedBySource) { const source = updatedMobs[sourceId]; if (!source || source.lastDamagedActorId === targetActorId) continue; updatedMobs[sourceId] = { ...source, lastDamagedActorId: targetActorId }; changedTarget = true; } if (changedTarget) set({ mobs: updatedMobs }); } get().advanceKillingSpree(now); return appliedDamage; }, tick: (deltaSeconds, now = combatNow()) => { const partySelfDrainTicks: Array<{ memberId: string; amount: number; sourceAbilityId: string }> = []; const partyHotTicks: Array<{ memberId: string; amount: number; sourceActorId: AggroActorId; sourceAbilityId: string; }> = []; const shadowfiendManaRestores: Array<{ ownerActorId: string; summonId: string; abilityId: string }> = []; set((state) => { const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1); const resourceProfile = resourceProfileForClass(state.classId); const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead); const angerManagementProfile = state.classId === "warrior" ? wow335WarriorAngerManagementProfile( state.talentRanks[WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID] ?? 0, ) : null; const angerManagementElapsedMs = angerManagementProfile ? state.warriorAngerManagementRageRemainderMs + Math.round(delta * 1_000) : 0; const angerManagementTicks = angerManagementProfile ? Math.floor(angerManagementElapsedMs / angerManagementProfile.tickIntervalMs) : 0; const warriorAngerManagementRageRemainderMs = angerManagementProfile ? angerManagementElapsedMs % angerManagementProfile.tickIntervalMs : 0; const auraExpiration = expireAuras(recoveryAuras(state, now), now); let activeAuras = [...synchronizedDeterrenceAuras(state, auraExpiration.active, now)]; const activeHunterPetIds = new Set(state.summons .filter((summon) => summon.hunterPet && summon.health > 0 && summon.expiresAt > now) .map((summon) => summon.id)); activeAuras = activeAuras.filter((aura) => !( aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_PROWL_TAG) && !activeHunterPetIds.has(aura.targetId) )); const greenGuardianProgress: Record = {}; const greenGuardianTargets = new Set(activeAuras .filter((aura) => aura.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG)) .map((aura) => aura.targetId)); for (const targetId of greenGuardianTargets) { const isPlayer = targetId === PLAYER_AURA_ENTITY_ID; const partyMember = isPlayer ? undefined : usePartyStore.getState().members.find((member) => member.id === targetId); const exactPair = isPlayer ? isRomMageDruidPair({ classId: state.classId, secondaryClassId: state.secondaryClassId }) : Boolean(partyMember && partyMember.health > 0 && isRomMageDruidPair({ classId: partyMember.classId, secondaryClassId: partyMember.secondaryClassId ?? undefined })); const position = isPlayer ? state.playerPosition : getPartyRuntimePosition(targetId); if (!exactPair || !position || (isPlayer && state.health <= 0)) continue; const advance = romMageDruidGreenGuardianAdvanceStationaryProgress( state.romMageDruidGreenGuardianStationaryProgress[targetId], position, Math.round(delta * 1_000), ); greenGuardianProgress[targetId] = advance.progress; if (advance.stacksGained > 0) { activeAuras = activeAuras.map((aura) => { if (aura.targetId !== targetId || !aura.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG)) return aura; let stacks = aura.stacks; for (let gained = 0; gained < advance.stacksGained; gained += 1) { stacks = romMageDruidGreenGuardianStacksAfterStationarySecond(stacks); } return stacks === aura.stacks ? aura : { ...aura, stacks }; }); } } const hasChampionRuneOverload = state.classId === "rom-champion" && activeAuras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(ROM_CHAMPION_RUNE_OVERLOAD_TAG)); const playerAuras = aurasForEntity(activeAuras, PLAYER_AURA_ENTITY_ID); const recoveryClockAdvance = state.health <= 0 ? { clock: undefined, pulseOffsetsMs: [] as readonly number[] } : advanceRomNativeRecoveryClock( state.romNativeRecoveryClock, state.statRuleset, "player", delta * 1_000, ); const nativeRecoveryBaseStats = state.statRuleset === "rom" && isRomClassId(state.classId) ? deriveCharacterStats( state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId, state.gender, "rom", ) : undefined; const nativeRecoveryBase = nativeRecoveryBaseStats?.romNative; let nativeHealthAfterRecovery = state.health <= 0 ? 0 : state.health; let nativeManaPulseAmount = 0; if (nativeRecoveryBase && state.health > 0) { const intervalStartAt = now - delta * 1_000; for (const offsetMs of recoveryClockAdvance.pulseOffsetsMs) { const pulseAt = intervalStartAt + offsetMs; const pulseStats = nativeRecoveryBaseStats ? auraAdjustedCharacterStats( nativeRecoveryBaseStats, state.auras.filter((aura) => aura.expiresAt === null || aura.expiresAt > pulseAt), PLAYER_AURA_ENTITY_ID, pulseAt, ).romNative : undefined; if (!pulseStats) continue; const amount = romNativeRecoveryPulseAmounts(pulseStats, "player", inCombat); nativeHealthAfterRecovery += amount.health; nativeManaPulseAmount += amount.mana; } } nativeHealthAfterRecovery = Math.min(state.maxHealth, nativeHealthAfterRecovery); const nativeManaMaximum = state.maxResourcePools.mana ?? state.maxResource; const nativeManaPoolEnabled = Boolean(nativeRecoveryBase?.usesMana && nativeManaMaximum > 0); const nativeManaPeriodicAmount = nativeManaPoolEnabled && state.health > 0 ? romNativePeriodicManaEffectDuring( state.auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID), now - delta * 1_000, now, nativeManaMaximum, ) : 0; const nativeManaAfterRecovery = nativeManaPoolEnabled ? clamp( (state.resourcePools.mana ?? (resourceProfile.type === "mana" ? state.resource : 0)) + nativeManaPulseAmount + nativeManaPeriodicAmount, 0, nativeManaMaximum, ) : undefined; const ordinaryBaseRegeneration = (resourceProfile.type === "rage" || resourceProfile.type === "runic-power") ? 0 : resourceProfile.regenerationPerSecond + state.gearStats.mp5 / 5 + state.gearStats.spirit * 0.02; const manaPercentRegeneration = resourceProfile.type === "mana" ? resolveAuraStatValue(0, playerAuras, "mana-regeneration-max-percent-per-second") * (state.maxResourcePools.mana ?? state.maxResource) : 0; const baseRegeneration = applyTalentModifierValue( ordinaryBaseRegeneration + manaPercentRegeneration, state.talentModifiers, "resource-regeneration-percent", ); const regeneration = resolveAuraResourceValue( resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"), playerAuras, "power", "regeneration", ); const activePool = nativeManaAfterRecovery !== undefined && resourceProfile.type === "mana" ? undefined : advanceCombatResourcePool(normalizeActorResourcePool( resourceProfile.type, state.resource, state.maxResource, regeneration, state.lastResourceSpentAt[resourceProfile.type] ?? 0, ), { inCombat: inCombat || hasChampionRuneOverload, actorLevel: state.level, now, deltaSeconds: delta, manaSpiritRegenerationPerSecond: state.gearStats.spirit * 0.02, }); let resource = activePool?.current ?? nativeManaAfterRecovery ?? state.resource; if (hasChampionRuneOverload) { resource = Math.max(0, resource - ROM_CHAMPION_RUNE_OVERLOAD_RAGE_PER_SECOND * delta); } const resourcePools: Partial> = { ...state.resourcePools }; const hasNativeRomWarriorRage = hasRomWarriorRagePool(state.classId, state.secondaryClassId); const passiveRates: Partial> = { mana: 4, rage: 0, energy: 10, focus: 6, "runic-power": 0, }; for (const [type, maximum] of Object.entries(state.maxResourcePools) as [ResourceType, number][]) { if (type === resourceProfile.type) continue; if (type === "mana" && nativeManaAfterRecovery !== undefined) { resourcePools.mana = nativeManaAfterRecovery; continue; } resourcePools[type] = advanceCombatResourcePool(normalizeActorResourcePool( type, resourcePools[type] ?? 0, maximum, passiveRates[type] ?? 0, state.lastResourceSpentAt[type] ?? 0, ), { inCombat, actorLevel: state.level, now, deltaSeconds: delta, manaSpiritRegenerationPerSecond: 0, }).current; } resourcePools[resourceProfile.type] = resource; const recoveryManaMaximum = state.maxResourcePools.mana ?? (resourceProfile.type === "mana" ? state.maxResource : 0); if (state.health > 0 && recoveryManaMaximum > 0) { resourcePools.mana = Math.min(recoveryManaMaximum, (resourcePools.mana ?? 0) + healerManaRecoveryAmount(activeAuras, PLAYER_AURA_ENTITY_ID, recoveryManaMaximum, delta)); if (resourceProfile.type === "mana") resource = resourcePools.mana; } if (angerManagementProfile && angerManagementTicks > 0) { const rageMaximum = state.maxResourcePools.rage ?? state.maxResource; const rage = Math.min( rageMaximum, (resourcePools.rage ?? 0) + angerManagementTicks * angerManagementProfile.ragePerTick, ); resourcePools.rage = rage; if (resourceProfile.type === "rage") resource = rage; } if (state.classId === "rom-champion" && (resourcePools.rage ?? 0) <= 0) { activeAuras = activeAuras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(ROM_CHAMPION_RUNE_OVERLOAD_TAG) )); } let shield = state.shield; let shieldExpiresAt = state.shieldExpiresAt; if (shield > 0 && shieldExpiresAt > 0 && now >= shieldExpiresAt) { shield = 0; shieldExpiresAt = 0; } const progression = progressionFromState(state); const work: MutableCastWork = { classId: state.classId, statRuleset: state.statRuleset, gender: state.gender, abilities: state.abilities, mobs: { ...state.mobs }, effects: state.effects.filter((effect) => effect.kind === "dot" && effect.warlockSeed), auras: activeAuras, summons: state.summons.filter((summon) => summon.expiresAt > now), areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now), combatEvents: [...state.combatEvents], actors: { ...state.actors }, cooldowns: { ...state.cooldowns }, health: state.health <= 0 ? 0 : state.statRuleset === "rom" ? nativeHealthAfterRecovery : Math.min( state.maxHealth, state.health + resolveAuraStatValue( state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01, playerAuras, "health-regeneration", ) * delta, ), maxHealth: state.maxHealth, shield, shieldExpiresAt, resource, maxResource: state.maxResource, resourcePools, maxResourcePools: { ...state.maxResourcePools }, lastResourceSpentAt: { ...state.lastResourceSpentAt }, comboPoints: state.comboPoints, comboTargetId: state.comboTargetId, deathKnightRunes: state.deathKnightRunes, progression, totalDamage: 0, totalHealing: 0, combatResults: [], talentRanks: state.talentRanks, talentModifiers: state.talentModifiers, gearStats: state.gearStats, raceId: state.raceId, secondaryClassId: state.secondaryClassId, }; for (const targetId of new Set(auraExpiration.expired.map((aura) => aura.targetId))) { applyAuraHealthTransitionInWork(work, targetId, state.auras, activeAuras); } const protectionExpirations = auraExpiration.expired.filter((aura) => ( aura.definition.utilityTags?.includes(ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG) && aura.expiresAt !== null )); const partyMembers = usePartyStore.getState().members; const playerGreenGuardian = state.classId === "rom-mage" && state.secondaryClassId === "rom-druid" ? state.abilities.find((ability) => ability.dbcSpellId === 494331 && ability.passive) : undefined; const playerHasGreenGuardian = Boolean(playerGreenGuardian && activeAuras.some((aura) => ( aura.sourceId === PLAYER_AURA_ENTITY_ID && aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(ROM_DRUID_GREEN_GUARDIAN_TAG) ))); for (const protection of protectionExpirations) { const isPlayerCaster = protection.sourceId === PLAYER_AURA_ENTITY_ID; const caster = isPlayerCaster ? undefined : partyMembers.find((member) => member.id === protection.sourceId); const greenGuardian = isPlayerCaster ? playerHasGreenGuardian ? playerGreenGuardian : undefined : caster?.classId === "rom-mage" && caster.secondaryClassId === "rom-druid" ? (() => { const catalogPassive = abilitiesForCharacter(caster.classId, caster.secondaryClassId) .find((ability) => ability.dbcSpellId === 494331 && ability.passive); return catalogPassive ? learnedAbilityForBuild(catalogPassive, caster.level, caster.talentRanks) : null; })() : undefined; if (!greenGuardian) continue; const targetId = protection.targetId === PLAYER_AURA_ENTITY_ID ? null : protection.targetId; const sourceActorId = caster?.id ?? PLAYER_AGGRO_ID; const hots = greenGuardian.effects.filter((effect): effect is Extract => ( effect.kind === "hot" )); work.effects = work.effects.filter((effect) => !( effect.kind === "hot" && effect.sourceAbilityId === greenGuardian.id && effect.sourceActorId === sourceActorId && effect.targetId === targetId )); for (const hot of hots) { const triggered = timedEffect( "hot", greenGuardian.id, sourceActorId, targetId, hot, hot.ticks, hot.intervalMs, protection.expiresAt!, ); work.effects.push(caster && hot.percentMaxHealth === undefined ? { ...triggered, fixedAmount: equipmentAdjustedCombatAmount( hot, caster.level, caster.gearStats, caster.classId, greenGuardian, 0, undefined, caster.raceId, caster.gender, caster.statRuleset ?? "wow", caster.secondaryClassId, ), } : triggered); } } let autoAttackTargetId = state.autoAttackTargetId; let nextAutoAttackAt = state.nextAutoAttackAt; let nextOffhandAutoAttackAt = state.nextOffhandAutoAttackAt; let autoAttackEvent: CharacterCombatAnimationEvent | null = null; let autoShotTargetId = state.autoShotTargetId; let nextAutoShotAt = state.nextAutoShotAt; const autoShotAbility = state.abilities.find((ability) => isRomScoutAutoShotAbility(ability)); if (autoShotTargetId) { const target = work.mobs[autoShotTargetId]; const rangedWeapon = playerEquippedRangedWeapon(state); if (!target || target.dead || state.health <= 0 || !rangedWeapon || !autoShotAbility || !isRomScoutRogueAutoShotOwner(state)) { autoShotTargetId = null; nextAutoShotAt = 0; } else { // RoM Auto Shot replaces weapon melee autos while this player mode is active. autoAttackTargetId = null; nextAutoAttackAt = 0; nextOffhandAutoAttackAt = 0; const targetPosition = getMobPosition(autoShotTargetId); const sourcePosition = combatSpatialActor(PLAYER_AGGRO_ID); const canAutoShot = !activeEnemyDisarmChannels(work.auras, PLAYER_AURA_ENTITY_ID, now).has("ranged") && !aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID, now) .some((aura) => aura.definition.utilityTags?.includes("pacified")) && !state.activeCast && (state.controlledUntil <= now || !enemyControlPreventsActions(state.controlMechanic)) && Math.sqrt(mobRuntimeDistanceSquared(autoShotTargetId, state.playerPosition)) <= Math.min(ROM_SCOUT_AUTO_SHOT_MAX_RANGE, autoShotAbility.range.max) && Boolean(targetPosition) && combatHasLineOfSight(state.playerPosition, targetPosition ?? state.playerPosition) && (!sourcePosition || isPositionInFacingArc(sourcePosition, targetPosition ?? state.playerPosition, Math.PI)); if (canAutoShot) { const profile = playerAutoShotProfile( state, rangedWeapon, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), now, ); if (nextAutoShotAt <= 0) { nextAutoShotAt = now + profile.intervalMs; } else if (now >= nextAutoShotAt) { nextAutoShotAt = now + profile.intervalMs; const healthCost = romScoutRogueBloodArrowRangedAutoAttackCost( state, work.auras, PLAYER_AURA_ENTITY_ID, work.maxHealth, now, ); if (healthCost === 0 || work.health > healthCost) { if (healthCost > 0) { work.health -= healthCost; work.progression.health = work.health; const playerActor = work.actors[PLAYER_AURA_ENTITY_ID]; if (playerActor) work.actors[PLAYER_AURA_ENTITY_ID] = { ...playerActor, health: work.health, alive: work.health > 0, }; } const result = resolveAndDamageMobInWork( state.classId, work, autoShotTargetId, profile.damage, now, playerThreatSource(state, autoShotAbility.id), "ranged", "physical", `${PLAYER_AGGRO_ID}:auto-shot:${autoShotTargetId}:${now}`, autoShotAbility, false, { isAutoAttack: true }, 0, profile.weaponSpeedMs, ); if (result.amount > 0) { autoAttackEvent = nextAnimationEvent("attack"); emitCombatPresentation({ occurredAt: now, phase: "release", actorGroup: "player", sourceActorId: PLAYER_AGGRO_ID, targetActorId: autoShotTargetId, abilityId: autoShotAbility.id, sourceSpellId: autoShotAbility.dbcSpellId, name: autoShotAbility.name, source: "runewaker", school: "physical", delivery: "projectile", origin: state.playerPosition, targetPosition: targetPosition ?? state.playerPosition, instantCast: true, }); } if (result.killed) { autoShotTargetId = null; nextAutoShotAt = 0; } } } } } } const autoAttackTarget = autoAttackTargetId ? work.mobs[autoAttackTargetId] : null; if (!autoAttackTarget || autoAttackTarget.dead || state.health <= 0) { autoAttackTargetId = null; nextAutoAttackAt = 0; nextOffhandAutoAttackAt = 0; } else { const autoAttackTargetPosition = getMobPosition(autoAttackTargetId!); const autoAttackSource = combatSpatialActor(PLAYER_AGGRO_ID); const canAutoAttack = ( !state.activeCast && !aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID, now) .some((aura) => aura.definition.utilityTags?.includes("pacified")) && !(state.classId === "rom-warden" && activeWardenNatureCrystal(work.summons, now)) && ( state.controlledUntil <= now || !enemyControlPreventsActions(state.controlMechanic) ) && Math.sqrt(mobRuntimeDistanceSquared(autoAttackTargetId!, state.playerPosition)) <= PLAYER_AUTO_ATTACK_RANGE && Boolean(autoAttackTargetPosition) && combatHasLineOfSight(state.playerPosition, autoAttackTargetPosition ?? state.playerPosition) && (!autoAttackSource || isPositionInFacingArc(autoAttackSource, autoAttackTargetPosition ?? state.playerPosition, Math.PI)) ); if (canAutoAttack) { const playerAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID); const currentForm = state.classId === "druid" ? wow335DruidShapeshiftFormForAuras(work.auras, PLAYER_AURA_ENTITY_ID, now) : undefined; const mainHandDisarmed = !(currentForm === "cat" || currentForm === "bear") && activeEnemyDisarmChannels(work.auras, PLAYER_AURA_ENTITY_ID, now).has("main-hand"); const nativeUnarmed = mainHandDisarmed && normalizeCharacterStatRuleset(state.statRuleset, state.classId) === "rom" ? nativeDisarmedMainHandAutoAttackProfile(deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset).romNative) : undefined; const offHandWeapon = state.classId === "rogue" || state.classId === "rom-rogue" || state.classId === "shaman" || state.classId === "death-knight" ? playerEquippedMeleeWeapon(state, "off-hand") : null; const supportsPlayerOffhandSwings = state.classId === "rogue" || state.classId === "rom-rogue" || state.classId === "shaman" || (state.classId === "death-knight" && Boolean(offHandWeapon)); const mainHandWeapon = supportsPlayerOffhandSwings ? playerEquippedMeleeWeapon(state, "main-hand") : null; const profile = playerAutoAttackProfile( state, playerAuras, (state.classId === "rogue" || state.classId === "rom-rogue") && offHandWeapon && !mainHandWeapon ? null : mainHandWeapon ?? undefined, now, ); const flurryActiveBeforeMainHand = wow335ShamanFlurryIsActive( work.auras, PLAYER_AURA_ENTITY_ID, now, ); if (hasRomChampionRagePool(state.classId, state.secondaryClassId)) { const rage = gainRomRage( state.classId, state.secondaryClassId, ROM_CHAMPION_RAGE_PER_SECOND * profile.hasteMultiplier * delta, work.resource, work.maxResource, work.resourcePools, work.maxResourcePools, ); work.resource = rage.resource; work.resourcePools = rage.resourcePools; } if (now >= nextAutoAttackAt) { const isDualWielding = Boolean(offHandWeapon); const mainHandSpeedMs = mainHandDisarmed && (normalizeCharacterStatRuleset(state.statRuleset, state.classId) === "wow" || nativeUnarmed) ? nativeUnarmed?.intervalMs ?? ENEMY_DISARM_UNARMED_MAIN_HAND.speedMs : mainHandWeapon?.speedMs ?? state.gearStats.meleeWeapon?.speedMs ?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS; const autoAttackSchool = mainHandDisarmed && (normalizeCharacterStatRuleset(state.statRuleset, state.classId) === "wow" || nativeUnarmed) ? "physical" : mainHandWeapon?.damageSchool ?? (offHandWeapon ? "physical" : state.gearStats.meleeWeapon?.damageSchool) ?? "physical"; const mainHandAutoAttackSeed = `${PLAYER_AGGRO_ID}:auto:${autoAttackTargetId}:${now}`; if (autoAttackTargetId && state.classId === "hunter") { work.summons = work.summons.map((summon) => summon.hunterPet && summon.ownerActorId === PLAYER_AGGRO_ID ? { ...summon, hunterPetPendingOwnerAttackTargetId: autoAttackTargetId } : summon); } const result = resolveAndDamageMobInWork( state.classId, work, autoAttackTargetId!, profile.damage, now, playerThreatSource(state), "melee", autoAttackSchool, mainHandAutoAttackSeed, undefined, false, { isAutoAttack: true, isDualWielding }, 0, mainHandSpeedMs, ); const runeWeaponMainHand = state.classId === "death-knight" && result.combatResult?.landed && result.amount > 0 ? maybeMirrorDeathKnightDancingRuneWeaponMeleeInWork( work, autoAttackTargetId!, result.combatResult.rawDamage, now, playerThreatSource(state, "player-auto-attack"), autoAttackSchool, ) : null; maybeProcWarriorUnbridledWrathInWork( work, mainHandSpeedMs, Boolean(result.combatResult?.landed && result.amount > 0), mainHandAutoAttackSeed, now, ); const autoAttackKilled = result.killed || Boolean(runeWeaponMainHand?.killed); if (result.amount > 0 && ( hasNativeRomWarriorRage || (!hasRomRagePool(state.classId, state.secondaryClassId) && work.maxResourcePools.rage !== undefined) )) { const rage = gainRomRage( state.classId, state.secondaryClassId, RAGE_PER_AUTO_ATTACK, work.resource, work.maxResource, work.resourcePools, work.maxResourcePools, ); work.resource = rage.resource; work.resourcePools = rage.resourcePools; } if (result.amount > 0) autoAttackEvent = nextAnimationEvent("attack"); if (result.amount > 0) { const targetPosition = getMobPosition(autoAttackTargetId!); emitCombatPresentation({ occurredAt: now, phase: "release", actorGroup: "player", sourceActorId: PLAYER_AGGRO_ID, targetActorId: autoAttackTargetId!, abilityId: "player-auto-attack", name: "Auto Attack", source: "fallback", school: autoAttackSchool, delivery: "melee", origin: state.playerPosition, targetPosition: targetPosition ?? state.playerPosition, }); } // Resolve the next swing interval from post-hit auras so a Flurry // proc affects the three swings following the critical strike. const flurryActiveAfterMainHand = wow335ShamanFlurryIsActive( work.auras, PLAYER_AURA_ENTITY_ID, now, ); const nextMainHandIntervalMs = flurryActiveBeforeMainHand || flurryActiveAfterMainHand ? playerAutoAttackProfile( state, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), mainHandWeapon, now, ).intervalMs : profile.intervalMs; nextAutoAttackAt = now + nextMainHandIntervalMs; if (autoAttackKilled) { autoAttackTargetId = null; nextAutoAttackAt = 0; nextOffhandAutoAttackAt = 0; } } if (!supportsPlayerOffhandSwings || !offHandWeapon || !autoAttackTargetId) { nextOffhandAutoAttackAt = 0; } else if (now >= nextOffhandAutoAttackAt) { const playerAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID); const flurryActiveBeforeOffhand = wow335ShamanFlurryIsActive( work.auras, PLAYER_AURA_ENTITY_ID, now, ); const offHandMasteryActive = state.classId === "rom-rogue" && playerAuras.some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG) )); const offhandProfile = playerOffhandAutoAttackProfile( state, offHandWeapon, playerAuras, offHandMasteryActive ? ROM_ROGUE_OFF_HAND_MASTERY_DAMAGE_PERCENT : 0, now, ); const offhandSchool = offHandWeapon.damageSchool; const offhandSeed = `${PLAYER_AGGRO_ID}:offhand-auto:${autoAttackTargetId}:${now}`; const offhandRules: CombatResultRules = { isAutoAttack: true, isDualWielding: true, ...(offHandMasteryActive ? { baseMissChancePercent: Math.max(0, baseMissChancePercent("melee", state.level, autoAttackTarget.level, true) - ROM_ROGUE_OFF_HAND_MASTERY_ACCURACY_PERCENT), } : {}), }; const result = resolveAndDamageMobInWork( state.classId, work, autoAttackTargetId, offhandProfile.damage, now, playerThreatSource(state, PLAYER_OFFHAND_AUTO_ATTACK_ID), "melee", offhandSchool, offhandSeed, undefined, false, offhandRules, 0, offHandWeapon.speedMs, ); const runeWeaponOffHand = state.classId === "death-knight" && result.combatResult?.landed && result.amount > 0 ? maybeMirrorDeathKnightDancingRuneWeaponMeleeInWork( work, autoAttackTargetId, result.combatResult.rawDamage, now, playerThreatSource(state, PLAYER_OFFHAND_AUTO_ATTACK_ID), offhandSchool, ) : null; if (result.combatResult?.landed) { maybeProcRogueCombatPotencyInWork(work, offhandSeed, now); } if (result.amount > 0) { autoAttackEvent = nextAnimationEvent("attack"); const targetPosition = getMobPosition(autoAttackTargetId); emitCombatPresentation({ occurredAt: now, phase: "release", actorGroup: "player", sourceActorId: PLAYER_AGGRO_ID, targetActorId: autoAttackTargetId, abilityId: PLAYER_OFFHAND_AUTO_ATTACK_ID, name: "Off-hand Auto Attack", source: "fallback", school: offhandSchool, delivery: "melee", origin: state.playerPosition, targetPosition: targetPosition ?? state.playerPosition, }); } const postAttackAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID); const postAttackOffHandMasteryActive = state.classId === "rom-rogue" && postAttackAuras.some((aura) => ( (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG) )); const flurryActiveAfterOffhand = wow335ShamanFlurryIsActive( work.auras, PLAYER_AURA_ENTITY_ID, now, ); const nextOffhandIntervalMs = flurryActiveBeforeOffhand || flurryActiveAfterOffhand ? playerOffhandAutoAttackProfile( state, offHandWeapon, postAttackAuras, postAttackOffHandMasteryActive ? ROM_ROGUE_OFF_HAND_MASTERY_DAMAGE_PERCENT : 0, now, ).intervalMs : offhandProfile.intervalMs; nextOffhandAutoAttackAt = now + nextOffhandIntervalMs; if (result.killed || runeWeaponOffHand?.killed) { autoAttackTargetId = null; nextAutoAttackAt = 0; nextOffhandAutoAttackAt = 0; } } } } const timedEffectTargetAlive = (effect: TimedCombatEffect): boolean => { if (effect.kind === "dot") { if (effect.tag === "rom-self-health-drain") { const casterBuffActive = work.auras.some((aura) => aura.targetId === effect.targetId && aura.sourceId === effect.sourceActorId && aura.definition.utilityTags?.includes("rom-self-health-drain")); if (!casterBuffActive) return false; if (effect.targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0; return Boolean(effect.targetId && work.actors[effect.targetId]?.alive); } const mob = effect.targetId ? work.mobs[effect.targetId] : null; return Boolean(mob && !mob.dead && mob.health > 0); } const targetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID; if (targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0; const summon = work.summons.find((candidate) => candidate.id === targetId); if (summon) return summon.health > 0; const actor = ensureCompanionActorInWork(work, targetId) ?? work.actors[targetId]; return Boolean(actor?.alive && actor.health > 0); }; for (const effect of state.effects) { if (effect.kind === "dot" && effect.warlockSeed && !work.effects.some((candidate) => candidate.id === effect.id)) continue; // Check before due ticks/procs, not only after applying their health changes. if (!timedEffectTargetAlive(effect)) continue; let remainingTicks = effect.remainingTicks; let nextTickAt = effect.nextTickAt; while (remainingTicks > 0 && nextTickAt <= now && timedEffectTargetAlive(effect)) { const warPrayerSealTick = effect.kind === "dot" && effect.tag === "rom-knight-war-prayer-seal"; const ability = abilityById(effect.sourceAbilityId); const hotSourcePartyMember = effect.kind === "hot" ? usePartyStore.getState().members.find((member) => member.id === effect.sourceActorId) : undefined; const hotSourceClassId = hotSourcePartyMember?.classId ?? (effect.sourceActorId === PLAYER_AGGRO_ID ? state.classId : null); const hotSourceTalentRanks = hotSourcePartyMember?.talentRanks ?? (effect.sourceActorId === PLAYER_AGGRO_ID ? work.talentRanks : {}); const hotMasterShapeshifterModifiers = hotSourceClassId === "druid" ? wow335DruidMasterShapeshifterModifiersForForm( hotSourceTalentRanks[WOW335_DRUID_MASTER_SHAPESHIFTER_TALENT_ID] ?? 0, wow335DruidShapeshiftFormForAuras(work.auras, effect.sourceActorId, nextTickAt), ).filter((modifier) => modifier.kind === "healing-percent") : []; const immolationCasterMember = effect.kind === "dot" && effect.tag === WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG ? usePartyStore.getState().members.find((member) => member.id === effect.sourceActorId) : null; const immolationTickSuppressed = effect.kind === "dot" && effect.tag === WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG && (effect.sourceActorId === PLAYER_AGGRO_ID ? state.controlledUntil > nextTickAt && enemyControlPreventsActions(state.controlMechanic) : Boolean(immolationCasterMember && immolationCasterMember.controlledUntil > nextTickAt && enemyControlPreventsActions(immolationCasterMember.controlMechanic))); const immolationCasterLevel = effect.sourceActorId === PLAYER_AGGRO_ID ? progression.level : immolationCasterMember?.level ?? work.actors[effect.sourceActorId]?.level ?? progression.level; const dotMageTalentOwner = effect.kind === "dot" ? wow335MageTalentOwnerForActor(work, effect.sourceActorId) : null; const amountOwnerMember = usePartyStore.getState().members.find((member) => member.id === effect.sourceActorId); const livingBombCanCrit = effect.kind === "dot" && ability?.name === "Living Bomb" && dotMageTalentOwner !== null && mageTalentRank(dotMageTalentOwner.talentRanks, "Empowered Fire", 3) >= 3; const scheduledTickAmount = effect.tickAmounts?.[effect.tickAmounts.length - remainingTicks]; const nativeTickAmount = effect.nativePeriodicDamageSnapshot ? nativePeriodicDamageTick(effect.nativePeriodicDamageSnapshot, effect.targetId ? work.mobs[effect.targetId]?.maxHealth ?? 0 : 0)?.damage : undefined; const sourceAmount = nativeTickAmount ?? scheduledTickAmount ?? effect.fixedAmount ?? (ability ? equipmentAdjustedCombatAmount( effect, amountOwnerMember?.level ?? progression.level, amountOwnerMember?.gearStats ?? state.gearStats, amountOwnerMember?.classId ?? state.classId, ability, 0, undefined, amountOwnerMember?.raceId ?? state.raceId, amountOwnerMember?.gender ?? state.gender, amountOwnerMember?.statRuleset ?? state.statRuleset, amountOwnerMember?.secondaryClassId ?? state.secondaryClassId, auraAdjustedCharacterStats(deriveCharacterStats( amountOwnerMember?.classId ?? state.classId, amountOwnerMember?.level ?? progression.level, amountOwnerMember?.gearStats ?? state.gearStats, amountOwnerMember?.raceId ?? state.raceId, {}, amountOwnerMember?.secondaryClassId ?? state.secondaryClassId, amountOwnerMember?.gender ?? state.gender, amountOwnerMember?.statRuleset ?? state.statRuleset, ), work.auras, effect.sourceActorId, nextTickAt).romNative, ) : Math.max(0, Math.round(effect.coefficient * playerCombatPower(progression.level)))); const percentMaxHealth = effect.kind === "hot" ? effect.percentMaxHealth ?? 0 : 0; const hotTargetMaxHealth = effect.targetId ? work.actors[effect.targetId]?.maxHealth ?? usePartyStore.getState().members.find((member) => member.id === effect.targetId)?.maxHealth ?? 0 : work.maxHealth; const baseAmount = percentMaxHealth > 0 ? romDruidPeriodicHealAmount(sourceAmount, hotTargetMaxHealth, percentMaxHealth) : sourceAmount; const unadjustedTickAmount = ability && effect.fixedAmount === undefined ? talentAdjustedAmount( baseAmount, [...state.talentModifiers, ...hotMasterShapeshifterModifiers], effect.kind === "dot" ? "damage-percent" : "healing-percent", ability, true, ) : baseAmount; const totalTicks = ability?.effects.find((candidate) => candidate.kind === "dot")?.ticks ?? 12; const tickNumber = totalTicks - remainingTicks + 1; const amount = state.classId === "warlock" && ability?.name === "Curse of Agony" ? warlockCurseOfAgonyTickAmount(unadjustedTickAmount, tickNumber) : unadjustedTickAmount; if (immolationTickSuppressed) { // Loss-of-control effects suppress the pulse and its linked attack-power debuff. } else if (warPrayerSealTick && effect.targetId) { const targetId = effect.targetId; work.comboPoints = clamp((work.comboTargetId === targetId ? work.comboPoints : 0) + 1, 0, 4); work.comboTargetId = targetId; } else if (effect.kind === "dot" && effect.tag === "rom-self-health-drain" && effect.targetId) { if (effect.targetId === PLAYER_AURA_ENTITY_ID) { const drained = Math.min(work.health, amount); work.health -= drained; work.progression.health = work.health; work.totalDamage += drained; recordCombatEventInWork(work, { occurredAt: nextTickAt, kind: "damage", sourceActorId: effect.sourceActorId, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: effect.sourceAbilityId, rawAmount: drained, effectiveAmount: drained, overheal: 0, absorbed: 0, critical: false, }); } else { partySelfDrainTicks.push({ memberId: effect.targetId, amount, sourceAbilityId: effect.sourceAbilityId }); } } else if (effect.kind === "dot" && effect.targetId) { if (effect.tag === WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG) { applyWarlockImmolationAttackPowerInWork( work, effect.targetId, effect.sourceActorId, effect.sourceAbilityId, immolationCasterLevel, nextTickAt, ); } const dotAttackKind = ability ? abilityAttackKind(state.classId, ability) : "spell"; const result = resolveAndDamageMobInWork( state.classId, work, effect.targetId, amount, nextTickAt, threatSourceForActor(state, effect.sourceActorId, effect.sourceAbilityId, true), dotAttackKind, effect.damageSchool ?? "physical", `${effect.id}:${effect.targetId}:${nextTickAt}:${remainingTicks}`, ability ?? undefined, true, { canMiss: false, canCrit: !effect.targetMitigationSnapshotted && (livingBombCanCrit || (effect.criticalChanceBonusPercent ?? 0) > 0), canDodge: false, canParry: false, canBlock: false, ...(effect.ignoreArmor ? { ignoreArmor: true } : {}), ...(effect.targetMitigationSnapshotted ? { targetMitigationSnapshotted: true, ignoreArmor: true, resistanceMode: "none" as const, } : {}), }, effect.criticalChanceBonusPercent ?? 0, ); const wanderingPlagueOwner = effect.sourceActorId === PLAYER_AGGRO_ID ? null : usePartyStore.getState().members.find((member) => member.id === effect.sourceActorId) ?? null; const wanderingPlagueOwnerClass = wanderingPlagueOwner?.classId ?? (effect.sourceActorId === PLAYER_AGGRO_ID ? state.classId : null); const wanderingPlagueTalentRanks = wanderingPlagueOwner?.talentRanks ?? (effect.sourceActorId === PLAYER_AGGRO_ID ? work.talentRanks : {}); const wanderingPlagueRank = wanderingPlagueOwnerClass === "death-knight" ? wanderingPlagueTalentRanks[WOW335_DEATH_KNIGHT_WANDERING_PLAGUE_TALENT_ID] ?? 0 : 0; if (effect.tag && wow335DeathKnightWanderingPlagueIsDiseaseTag(effect.tag) && effect.targetId && wanderingPlagueRank > 0 && wanderingPlagueOwnerClass === "death-knight" && result.combatResult?.landed === true && result.amount > 0) { const ownerLevel = wanderingPlagueOwner?.level ?? work.progression.level; const ownerStats = playerCharacterStats( "death-knight", ownerLevel, wanderingPlagueOwner?.gearStats ?? work.gearStats, wanderingPlagueOwner?.raceId ?? work.raceId, wanderingPlagueOwner?.secondaryClassId ?? null, wanderingPlagueOwner ? talentModifiersForRanks(wanderingPlagueTalentRanks, "death-knight") : work.talentModifiers, wanderingPlagueOwner?.gender ?? work.gender, wanderingPlagueOwner?.statRuleset ?? work.statRuleset, ); const ownerMeleeCritPercent = applyAuraStats( ownerStats, aurasForEntity(work.auras, effect.sourceActorId), ).meleeCritPercent + wow335DeathKnightWanderingPlagueMeleeCritBonusPercentForTalentRanks(wanderingPlagueTalentRanks); const cooldownKey = `${WOW335_DEATH_KNIGHT_WANDERING_PLAGUE_DAMAGE_ABILITY_ID}:${effect.sourceActorId}`; const proc = wow335DeathKnightWanderingPlagueProcForDiseaseTick( wanderingPlagueRank, effect.tag, result.amount, ownerMeleeCritPercent, stableCombatUnit(`${effect.id}:${effect.targetId}:${nextTickAt}:${remainingTicks}:wandering-plague`), nextTickAt, work.cooldowns[cooldownKey] ?? 0, ); const center = getMobPosition(effect.targetId); if (proc && center) { work.cooldowns[cooldownKey] = proc.nextReadyAt; for (const splashTargetId of nearbyMobRuntimeIds(center, proc.targetRadiusYards)) { const splashTarget = work.mobs[splashTargetId]; if (!splashTarget || splashTarget.dead || splashTarget.health <= 0 || splashTarget.statuses.some((status) => status.breakOnDamage && status.endsAt > nextTickAt)) { continue; } resolveAndDamageMobInWork( "death-knight", work, splashTargetId, proc.extraDamage, nextTickAt, threatSourceForActor(state, effect.sourceActorId, WOW335_DEATH_KNIGHT_WANDERING_PLAGUE_DAMAGE_ABILITY_ID, true), "spell", "shadow", `${effect.id}:${effect.targetId}:${nextTickAt}:${remainingTicks}:wandering-plague:${splashTargetId}`, undefined, true, { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, ); } } } const frostbiteRank = dotMageTalentOwner?.talentRanks[WOW335_MAGE_FROSTBITE_TALENT_ID] ?? 0; if (effect.sourceActorId !== PLAYER_AGGRO_ID && effect.damageSchool === "frost" && frostbiteRank > 0) { applyMageFrostbiteInWork( work, effect.sourceActorId, effect.targetId, effect.sourceAbilityId, frostbiteRank, effect.damageSchool, result.combatResult?.landed === true, result.amount, stableCombatUnit(`${effect.id}:${effect.targetId}:${nextTickAt}:${remainingTicks}:mage-frostbite`), nextTickAt, ); } if (result.amount > 0 && ability) { maybeProcHunterLockAndLoadInWork( work, effect.sourceActorId, effect.sourceAbilityId, ability.name, "periodic", effect.targetId, nextTickAt, ); } grantDamageResourceInWork(work, effect, result.amount, effect.sourceActorId, effect.sourceAbilityId, nextTickAt); if (effect.tag === "wow335-mage-ignite" && result.amount > 0) { const empoweredFireRank = dotMageTalentOwner ? mageTalentRank(dotMageTalentOwner.talentRanks, "Empowered Fire", 3) : 0; if (dotMageTalentOwner && empoweredFireRank > 0) { const baseMana = baseResourcesFor("mage", dotMageTalentOwner.level).mana; const restored = Math.round(baseMana * 0.02); grantDamageResourceInWork(work, { onDamageResource: { resource: "mana", amount: restored }, }, result.amount, effect.sourceActorId, effect.sourceAbilityId, nextTickAt); } } } else if (effect.kind === "hot") { const hotTargetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID; const modifiedHealing = Math.round(resolveAuraHealingValue( resolveAuraHealingValue( amount, aurasForEntity(work.auras, effect.sourceActorId), "done", ), aurasForEntity(work.auras, hotTargetId), "taken", )); if (effect.targetId) { const target = ensureCompanionActorInWork(work, hotTargetId) ?? work.actors[hotTargetId] ?? (work.summons.some((summon) => summon.id === hotTargetId) ? combatActorFromSummon(work.summons.find((summon) => summon.id === hotTargetId)!, progression.level, work.auras) : null); if (target?.alive) partyHotTicks.push({ memberId: target.id, amount: modifiedHealing, sourceActorId: effect.sourceActorId, sourceAbilityId: effect.sourceAbilityId, }); } else { const healed = healPlayerInWork(work, modifiedHealing); work.totalHealing += healed; recordCombatEventInWork(work, { occurredAt: nextTickAt, kind: "healing", sourceActorId: effect.sourceActorId, targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: effect.sourceAbilityId, rawAmount: modifiedHealing, effectiveAmount: healed, overheal: Math.max(0, modifiedHealing - healed), absorbed: 0, critical: false, }); addHealingThreatInWork( work, threatSourceForActor(state, effect.sourceActorId, effect.sourceAbilityId, true), healed, ); } } if (!warPrayerSealTick && !immolationTickSuppressed) evaluateProcsInWork(work, { trigger: "periodic", subjectId: effect.sourceActorId, otherId: effect.targetId ?? PLAYER_AURA_ENTITY_ID, abilityId: effect.sourceAbilityId, amount, ...(effect.damageSchool ? { school: effect.damageSchool } : {}), }, nextTickAt); if (!warPrayerSealTick && !immolationTickSuppressed && ability) { const sourceActorId = effect.sourceActorId; const actorGroup = sourceActorId === PLAYER_AGGRO_ID ? "player" : "party"; const origin = sourceActorId === PLAYER_AGGRO_ID ? state.playerPosition : getPartyRuntimePosition(sourceActorId) ?? state.playerPosition; const targetPosition = effect.targetId ? getMobPosition(effect.targetId) ?? getPartyRuntimePosition(effect.targetId) : state.playerPosition; emitCombatPresentation({ occurredAt: nextTickAt, phase: "tick", actorGroup, sourceActorId, ...(effect.targetId ? { targetActorId: effect.targetId } : {}), abilityId: ability.id, sourceSpellId: ability.dbcSpellId, name: ability.name, source: presentationSourceForAbility(ability), school: presentationSchoolForAbility(ability), delivery: presentationDeliveryForAbility(ability), origin, targetPosition: targetPosition ?? origin, }); } remainingTicks -= 1; nextTickAt += effect.intervalMs; } if (effect.kind === "dot" && remainingTicks === 0 && abilityById(effect.sourceAbilityId)?.name === "Living Bomb") { detonateLivingBombInWork(work, effect, nextTickAt - effect.intervalMs); } if (remainingTicks > 0 && timedEffectTargetAlive(effect)) { const updated = remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt ? effect : { ...effect, remainingTicks, nextTickAt }; if (effect.kind === "dot" && effect.warlockSeed) { const existing = work.effects.find((candidate) => candidate.id === effect.id); if (existing) work.effects = work.effects.map((candidate) => ( candidate.id === effect.id ? { ...updated, warlockSeed: existing.warlockSeed } : candidate )); } else work.effects.push(updated); } } work.areaEffects = work.areaEffects.flatMap((area) => { let nextTickAt = area.nextTickAt; const catalogAbility = abilityById(area.sourceAbilityId); const areaMageTalentOwner = wow335MageTalentOwnerForActor(work, area.sourceActorId); const trapOwner = area.sourceActorId === PLAYER_AGGRO_ID ? state.classId === "hunter" ? null : undefined : usePartyStore.getState().members.find((member) => member.id === area.sourceActorId); const ability = catalogAbility ? abilityAtLevel( catalogAbility, trapOwner?.level ?? state.level, trapOwner ? learnedAbilityRank(catalogAbility, trapOwner.talentRanks) : undefined, ) : null; const isHunterTrap = Boolean(area.hunterTrap && ( (area.sourceActorId === PLAYER_AGGRO_ID && state.classId === "hunter") || trapOwner?.classId === "hunter" )); if (isHunterTrap && area.hunterTrap) { const zoneSlowSourceId = `hunter-trap-zone:${area.id}`; if (area.expiresAt <= now) { if (area.hunterTrap.kind === "frost-zone") { for (const [id, mob] of Object.entries(work.mobs)) { if (mob.statuses.some((status) => status.sourceAbilityId === zoneSlowSourceId)) { work.mobs[id] = { ...mob, statuses: mob.statuses.filter((status) => status.sourceAbilityId !== zoneSlowSourceId), }; } } } return []; } const sourceAbilities = trapOwner ? abilitiesForCharacter(trapOwner.classId, trapOwner.secondaryClassId) : work.abilities; const trapCatalogAbility = sourceAbilities.find((candidate) => candidate.id === area.sourceAbilityId) ?? catalogAbility; const trapAbility = trapCatalogAbility ? abilityAtLevel( trapCatalogAbility, trapOwner?.level ?? state.level, learnedAbilityRank(trapCatalogAbility, trapOwner?.talentRanks ?? work.talentRanks), ) : null; if (area.hunterTrap.kind === "frost-zone") { const ownerTalentRanks = trapOwner?.talentRanks ?? work.talentRanks; const entrapmentRank = Math.max(0, Math.min(3, Math.trunc( ownerTalentRanks[WOW335_HUNTER_ENTRAPMENT_TALENT_ID] ?? 0, ))); const entrapment = entrapmentRank > 0 ? entrapmentRankMechanics(entrapmentRank) : null; const slowMagnitude = Math.min(0.95, 0.5 + (entrapment?.frostTrapSlowBonus ?? 0)); const inZone = (id: string): boolean => { const position = getMobPosition(id); return Boolean(position && !work.mobs[id]?.dead && Math.sqrt(distanceSquared(area.position, position)) <= area.radius && combatHasLineOfSight(area.position, position)); }; // Remove this zone's slow as soon as a mob leaves it; refresh it while // the mob remains inside so movement speed is continuously reduced. for (const [id, mob] of Object.entries(work.mobs)) { if (inZone(id) || !mob.statuses.some((status) => status.sourceAbilityId === zoneSlowSourceId)) continue; work.mobs[id] = { ...mob, statuses: mob.statuses.filter((status) => status.sourceAbilityId !== zoneSlowSourceId), }; } for (const id of nearbyMobRuntimeIds(area.position, area.radius)) { if (!inZone(id)) continue; const mob = work.mobs[id]; if (!mob) continue; work.mobs[id] = applyStatus(mob, { kind: "status", status: "slow", durationMs: Math.max(2_500, area.tickIntervalMs + 500), magnitude: slowMagnitude, }, zoneSlowSourceId, now, area.sourceActorId); } while (nextTickAt <= now && nextTickAt < area.expiresAt) { if (entrapment && entrapment.rootProcChance > 0) { for (const id of nearbyMobRuntimeIds(area.position, area.radius)) { if (!inZone(id)) continue; const mob = work.mobs[id]; if (!mob || stableCombatUnit( `${area.sourceActorId}:${area.id}:${id}:${nextTickAt}:wow335-hunter-entrapment`, ) >= entrapment.rootProcChance) continue; work.mobs[id] = applyStatus(mob, { kind: "status", status: "root", durationMs: entrapment.rootDurationMs, }, trapAbility?.id ?? area.sourceAbilityId, nextTickAt, area.sourceActorId); } } nextTickAt += area.tickIntervalMs; } return [nextTickAt === area.nextTickAt ? area : { ...area, nextTickAt }]; } const entrantId = nearbyMobRuntimeIds(area.position, area.hunterTrap.triggerRadius) .filter((id) => { const position = getMobPosition(id); return Boolean(position && !work.mobs[id]?.dead && Math.sqrt(distanceSquared(area.position, position)) <= area.hunterTrap!.triggerRadius && combatHasLineOfSight(area.position, position)); }) .sort((left, right) => ( distanceSquared(area.position, getMobPosition(left) ?? area.position) - distanceSquared(area.position, getMobPosition(right) ?? area.position) || left.localeCompare(right) ))[0]; if (!entrantId) return [area]; if (area.hunterTrap.kind === "freezing") { const target = work.mobs[entrantId]; const freezingTrap = trapAbility && ["Freezing Arrow", "Freezing Trap"].includes(trapAbility.name) ? trapAbility : null; if (target && freezingTrap) { const trapped = applyStatus(target, { kind: "status", status: "freeze", durationMs: area.hunterTrap.freezeDurationMs ?? 20_000, breakOnDamage: true, }, freezingTrap.id, now, area.sourceActorId); work.mobs[entrantId] = trapped; if (trapped.statuses.some((status) => status.kind === "freeze" && status.sourceAbilityId === freezingTrap.id && status.sourceActorId === area.sourceActorId && status.endsAt > now)) { maybeProcHunterLockAndLoadInWork( work, area.sourceActorId, area.sourceAbilityId, freezingTrap.name, "trap", entrantId, now, ); } } return []; } if (area.hunterTrap.kind === "frost") { const durationMs = 30_000; const frostZone: CombatAreaEffect = { ...area, radius: 10, expiresAt: now + durationMs, tickIntervalMs: 2_000, nextTickAt: now + 2_000, hunterTrap: { ...area.hunterTrap, kind: "frost-zone" }, }; return [frostZone]; } if (area.hunterTrap.kind === "immolation") { if (trapAbility) { const periodic = trapAbility.effects.find((effect) => effect.kind === "dot" && effect.tag === "hunter-immolation-trap"); if (periodic && periodic.kind === "dot") { const ownerLevel = trapOwner?.level ?? state.level; const ownerGearStats = trapOwner?.gearStats ?? state.gearStats; const ownerClassId = trapOwner?.classId ?? state.classId; const ownerModifiers = trapOwner ? talentModifiersForRanks(trapOwner.talentRanks, trapOwner.classId) : work.talentModifiers; const tickDamage = talentAdjustedAmount( equipmentAdjustedCombatAmount( periodic, ownerLevel, ownerGearStats, ownerClassId, trapAbility, 0, undefined, trapOwner?.raceId ?? state.raceId, trapOwner?.gender ?? state.gender, trapOwner?.statRuleset ?? state.statRuleset, trapOwner?.secondaryClassId ?? state.secondaryClassId, ), ownerModifiers, "damage-percent", trapAbility, true, ); work.effects.push(timedEffect( "dot", trapAbility.id, area.sourceActorId, entrantId, { ...periodic, fixedAmount: tickDamage }, periodic.ticks, periodic.intervalMs, now, periodic.tag, periodic.school ?? "fire", )); } } return []; } const explosiveTrap = trapAbility; if (!explosiveTrap) return []; const ownerLevel = trapOwner?.level ?? state.level; const ownerGearStats = trapOwner?.gearStats ?? state.gearStats; const ownerClassId = trapOwner?.classId ?? state.classId; const ownerModifiers = trapOwner ? talentModifiersForRanks(trapOwner.talentRanks, trapOwner.classId) : work.talentModifiers; const directScaling = area.hunterTrap.directDamageScaling; if (directScaling) { const directDamage = talentAdjustedAmount( equipmentAdjustedCombatAmount( directScaling, ownerLevel, ownerGearStats, ownerClassId, explosiveTrap, 0, undefined, trapOwner?.raceId ?? state.raceId, trapOwner?.gender ?? state.gender, trapOwner?.statRuleset ?? state.statRuleset, trapOwner?.secondaryClassId ?? state.secondaryClassId, ), ownerModifiers, "damage-percent", explosiveTrap, ); for (const id of nearbyMobRuntimeIds(area.position, 10)) { const position = getMobPosition(id); if (!position || work.mobs[id]?.dead || !combatHasLineOfSight(area.position, position)) continue; resolveAndDamageMobInWork( ownerClassId, work, id, directDamage, now, threatSourceForActor(state, area.sourceActorId, explosiveTrap.id, true), "spell", "fire", `${area.id}:${id}:${now}:explosive-trap-impact`, explosiveTrap, false, { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, ); } } const periodic = explosiveTrap.effects.filter((effect) => effect.kind === "dot"); const tickIntervalMs = periodic.length ? Math.min(...periodic.map((effect) => effect.intervalMs)) : 2_000; const durationMs = periodic.length ? Math.max(...periodic.map((effect) => effect.ticks * effect.intervalMs)) : 20_000; const damagePerTick = periodic.reduce((sum, effect) => sum + talentAdjustedAmount( equipmentAdjustedCombatAmount(effect, ownerLevel, ownerGearStats, ownerClassId, explosiveTrap, 0, undefined, trapOwner?.raceId ?? state.raceId, trapOwner?.gender ?? state.gender, trapOwner?.statRuleset ?? state.statRuleset, trapOwner?.secondaryClassId ?? state.secondaryClassId), ownerModifiers, "damage-percent", explosiveTrap, true, ), 0); return [{ ...area, radius: 10, ...(periodic.length ? { fixedDamagePerTick: damagePerTick } : {}), expiresAt: now + durationMs, tickIntervalMs, nextTickAt: now + tickIntervalMs, hunterTrap: undefined, }]; } while (ability && nextTickAt <= now && nextTickAt < area.expiresAt) { const resolvedFixedKinds = new Set<"dot" | "hot">(); for (const periodic of ability.effects) { if (periodic.kind !== "dot" && periodic.kind !== "hot") continue; const fixedAmount = periodic.kind === "dot" ? area.fixedDamagePerTick : area.fixedHealingPerTick; if (fixedAmount !== undefined && resolvedFixedKinds.has(periodic.kind)) continue; if (fixedAmount !== undefined) resolvedFixedKinds.add(periodic.kind); const areaOwnerLevel = trapOwner?.level ?? state.level; const areaOwnerGearStats = trapOwner?.gearStats ?? state.gearStats; const areaOwnerClassId = trapOwner?.classId ?? state.classId; const areaOwnerModifiers = trapOwner ? talentModifiersForRanks(trapOwner.talentRanks, trapOwner.classId) : state.talentModifiers; const amount = fixedAmount ?? talentAdjustedAmount( equipmentAdjustedCombatAmount(periodic, areaOwnerLevel, areaOwnerGearStats, areaOwnerClassId, ability, 0, undefined, trapOwner?.raceId ?? state.raceId, trapOwner?.gender ?? state.gender, trapOwner?.statRuleset ?? state.statRuleset, trapOwner?.secondaryClassId ?? state.secondaryClassId), areaOwnerModifiers, periodic.kind === "dot" ? "damage-percent" : "healing-percent", ability, true, ); if (periodic.kind === "dot") { for (const id of nearbyMobRuntimeIds(area.position, area.radius)) { const position = getMobPosition(id); if (!position || work.mobs[id]?.dead || !combatHasLineOfSight(area.position, position)) continue; const periodicSchool = abilityDamageSchool(state.classId, ability); const result = resolveAndDamageMobInWork( areaOwnerClassId, work, id, amount, nextTickAt, threatSourceForActor(state, area.sourceActorId, ability.id, true), abilityAttackKind(state.classId, ability), periodicSchool, `${area.id}:${id}:${nextTickAt}`, ability, true, { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, ); const frostbiteRank = areaMageTalentOwner?.talentRanks[WOW335_MAGE_FROSTBITE_TALENT_ID] ?? 0; if (area.sourceActorId !== PLAYER_AGGRO_ID && periodicSchool === "frost" && frostbiteRank > 0) { applyMageFrostbiteInWork( work, area.sourceActorId, id, ability.id, frostbiteRank, periodicSchool, result.combatResult?.landed === true, result.amount, stableCombatUnit(`${area.id}:${id}:${nextTickAt}:mage-frostbite`), nextTickAt, ); } if (result.amount > 0) { maybeProcHunterLockAndLoadInWork( work, area.sourceActorId, ability.id, ability.name, "periodic", id, nextTickAt, ); } } } else { const partyMembers = usePartyStore.getState().members; for (const friendlyId of [PLAYER_AURA_ENTITY_ID, ...partyMembers.map((member) => member.id)]) { const position = friendlyId === PLAYER_AURA_ENTITY_ID ? state.playerPosition : getPartyRuntimePosition(friendlyId); if (!position || Math.sqrt(distanceSquared(area.position, position)) > area.radius || !combatHasLineOfSight(area.position, position)) continue; const member = partyMembers.find((candidate) => candidate.id === friendlyId); if (member ? member.health <= 0 : work.health <= 0) continue; const healed = member ? healCompanionActorInWork(work, friendlyId, amount) : healPlayerInWork(work, amount); work.totalHealing += healed; recordCombatEventInWork(work, { occurredAt: nextTickAt, kind: "healing", sourceActorId: area.sourceActorId, targetActorId: friendlyId, abilityId: ability.id, rawAmount: amount, effectiveAmount: healed, overheal: Math.max(0, amount - healed), absorbed: 0, critical: false, }); } } } nextTickAt += area.tickIntervalMs; } return [nextTickAt === area.nextTickAt ? area : { ...area, nextTickAt }]; }); work.summons = work.summons.map((summon) => { if (summon.health <= 0) { work.auras = work.auras.filter((aura) => !( aura.targetId === summon.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_PROWL_TAG) )); return summon; } const hunterPetOwnerMember = summon.hunterPet && summon.ownerActorId !== PLAYER_AGGRO_ID ? usePartyStore.getState().members.find((member) => member.id === summon.ownerActorId) : undefined; if (summon.hunterPet) { const ownerBaseStats = summon.ownerActorId === PLAYER_AGGRO_ID ? playerCharacterStats( work.classId, work.progression.level, work.gearStats, work.raceId, work.secondaryClassId, work.talentModifiers, work.gender, work.statRuleset, ) : hunterPetOwnerMember ? playerCharacterStats( hunterPetOwnerMember.classId, hunterPetOwnerMember.level, hunterPetOwnerMember.gearStats, hunterPetOwnerMember.raceId, hunterPetOwnerMember.secondaryClassId ?? null, talentModifiersForRanks(hunterPetOwnerMember.talentRanks, hunterPetOwnerMember.classId), hunterPetOwnerMember.gender, hunterPetOwnerMember.statRuleset ?? "wow", ) : undefined; const ownerAuraId = summon.ownerActorId === PLAYER_AGGRO_ID ? PLAYER_AURA_ENTITY_ID : summon.ownerActorId; const ownerStats = ownerBaseStats ? applyAuraStats(ownerBaseStats, aurasForEntity(work.auras, ownerAuraId)) : undefined; if (ownerStats) { const profile = wow335HunterPetProfileForLevel( hunterPetOwnerMember?.level ?? work.progression.level, summon.hunterPet.family, ownerStats, ); const effectiveStats = profile.effectiveStats; if (effectiveStats.maxHealth !== summon.maxHealth || effectiveStats.averageAutoAttackDamage !== summon.attackDamage || effectiveStats.attackPower !== summon.hunterPet.effectiveStats.attackPower || effectiveStats.inheritedStamina !== summon.hunterPet.effectiveStats.inheritedStamina || effectiveStats.inheritedRangedAttackPower !== summon.hunterPet.effectiveStats.inheritedRangedAttackPower) { summon = { ...summon, maxHealth: effectiveStats.maxHealth, health: Math.min(summon.health, effectiveStats.maxHealth), attackDamage: effectiveStats.averageAutoAttackDamage, hunterPet: profile, }; } } } if (summon.health <= 0) return summon; if (summon.hunterPet) { const focus = summon.resources.focus ?? normalizeActorResourcePool("focus", 100, 100, 5, now); const elapsedMs = Math.max(0, now - (summon.hunterPetFocusUpdatedAt ?? now)); const ownerIsHunter = summon.ownerActorId === PLAYER_AGGRO_ID ? work.classId === "hunter" : hunterPetOwnerMember?.classId === "hunter"; const bestialDisciplineRank = ownerIsHunter ? summon.ownerActorId === PLAYER_AGGRO_ID ? work.talentRanks[WOW335_HUNTER_BESTIAL_DISCIPLINE_TALENT_ID] ?? 0 : hunterPetOwnerMember?.talentRanks[WOW335_HUNTER_BESTIAL_DISCIPLINE_TALENT_ID] ?? 0 : 0; const focusRegenerationPerSecond = wow335HunterPetFocusRegenerationPerSecond( WOW335_HUNTER_PET_BASE_FOCUS_REGENERATION_PER_SECOND, bestialDisciplineRank, ); summon = { ...summon, resources: { ...summon.resources, focus: { ...focus, regenerationPerSecond: focusRegenerationPerSecond, current: Math.min(focus.maximum, focus.current + focusRegenerationPerSecond * elapsedMs / 1_000), }, }, hunterPetFocusUpdatedAt: now, }; } if (summon.kind !== "totem" && !summon.antiMagicZone) { const prowl = summon.hunterPet?.prowl; const prowlActive = prowl && work.auras.some((aura) => aura.targetId === summon.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_PROWL_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); const fearPosition = sourceFearSummonPosition({ id: summon.id, position: summon.position, auras: work.auras, now, deltaSeconds: delta, movementMultiplier: prowlActive ? Math.max(0, 1 - prowl.movementSpeedReductionPercent / 100) : 1 }); if (fearPosition) return { ...summon, position: fearPosition, cast: null }; } if (summon.controlledUntil > now || summon.cast || (summon.attackDamage <= 0 && !summon.healingPulse && !summon.antiMagicZone)) return summon; const readyToAct = summon.nextActionAt <= now && summon.globalCooldownEndsAt <= now; const readyToSwing = summon.nextActionAt <= now; const summonAbility = abilityById(summon.sourceAbilityId); if (summon.antiMagicZone) { if (!readyToAct) return summon; applyDeathKnightAntiMagicZoneInWork( work, summon.position, summon.antiMagicZone.radius, Math.max(0, summon.expiresAt - now), summon.antiMagicZone.capacityBase, summon.antiMagicZone.capacityPerAttackPower, summon.antiMagicZone.absorbFraction, now, state.playerPosition, ); return { ...summon, nextActionAt: now + summon.antiMagicZone.intervalMs }; } if (summon.kind === "totem" && summon.healingPulse) { if (!readyToAct) return summon; const partyMembers = usePartyStore.getState().members; const inRange = [ { id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition, }, ...partyMembers.map((member) => { const actor = ensureCompanionActorInWork(work, member.id); return { id: member.id, health: actor?.health ?? member.health, maxHealth: actor?.maxHealth ?? member.maxHealth, position: getPartyRuntimePosition(member.id), }; }), ].filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth && candidate.position && Math.sqrt(distanceSquared(summon.position, candidate.position)) <= summon.healingPulse!.radius && combatHasLineOfSight(summon.position, candidate.position)); let totalPulseHealing = 0; for (const recipient of inRange) { const pulseBaseAmount = summonAbility ? talentAdjustedAmount( summon.healingPulse.amount, state.talentModifiers, "healing-percent", summonAbility, true, ) : summon.healingPulse.amount; const requested = Math.round(resolveAuraHealingValue( resolveAuraHealingValue( pulseBaseAmount, aurasForEntity(work.auras, summon.ownerActorId), "done", ), aurasForEntity(work.auras, recipient.id), "taken", )); const healed = recipient.id === PLAYER_AURA_ENTITY_ID ? healPlayerInWork(work, requested) : healCompanionActorInWork(work, recipient.id, requested); if (healed <= 0) continue; totalPulseHealing += healed; recordCombatEventInWork(work, { occurredAt: now, kind: "healing", sourceActorId: summon.id, targetActorId: recipient.id, abilityId: summon.sourceAbilityId, rawAmount: requested, effectiveAmount: healed, overheal: Math.max(0, requested - healed), absorbed: 0, critical: false, }); evaluateProcsInWork(work, { trigger: "periodic", subjectId: summon.ownerActorId, otherId: recipient.id, abilityId: summon.sourceAbilityId, amount: healed, }, now); } work.totalHealing += totalPulseHealing; addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId, }, totalPulseHealing); return { ...summon, nextActionAt: now + summon.healingPulse.intervalMs }; } if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) { if (!readyToAct) return summon; const partyMembers = usePartyStore.getState().members; const injured = [ { id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition }, ...partyMembers.map((member) => ({ id: member.id, health: member.health, maxHealth: member.maxHealth, position: getPartyRuntimePosition(member.id), })), ].filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth && candidate.position && Math.sqrt(distanceSquared(summon.position, candidate.position)) <= 30 && combatHasLineOfSight(summon.position, candidate.position)) .sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth); const target = injured[0]; if (target) { const healed = target.id === PLAYER_AURA_ENTITY_ID ? healPlayerInWork(work, summon.attackDamage) : healCompanionActorInWork(work, target.id, summon.attackDamage); work.totalHealing += healed; addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed); recordCombatEventInWork(work, { occurredAt: now, kind: "healing", sourceActorId: summon.id, targetActorId: target.id, abilityId: summon.sourceAbilityId, rawAmount: summon.attackDamage, effectiveAmount: healed, overheal: summon.attackDamage - healed, absorbed: 0, critical: false, }); } return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }; } let hunterPetDecision: ReturnType | null = null; if (summon.hunterPet) { const eligible = Object.values(work.mobs).filter((mob) => !mob.dead && !mob.despawned && (mob.navigationEvadeUntil ?? 0) <= now && Boolean(getMobPosition(mob.id))); const eligibleTargetIds = new Set(eligible.map((mob) => mob.id)); const inMeleeRangeTargetIds = new Set(eligible.flatMap((mob) => { const mobPosition = getMobPosition(mob.id); return mobPosition && distanceSquared(summon.position, mobPosition) <= 25 ? [mob.id] : []; })); const ownerTargetId = summon.hunterPetPendingOwnerAttackTargetId ?? null; const ownerPosition = summon.ownerActorId === PLAYER_AGGRO_ID ? state.playerPosition : getPartyRuntimePosition(summon.ownerActorId); const petAtStayPosition = Boolean(summon.hunterPetStayPosition && distanceSquared(summon.position, summon.hunterPetStayPosition) <= 1); hunterPetDecision = wow335HunterPetTargetPolicy({ command: summon.hunterPetCommand ?? WOW335_HUNTER_PET_DEFAULT_COMMAND, reactMode: summon.hunterPetReactMode ?? WOW335_HUNTER_PET_DEFAULT_REACT_MODE, eligibleTargetIds, commandTargetId: summon.hunterPetCommandTargetId, isCommandAttack: summon.hunterPetIsCommandAttack, currentTargetId: summon.targetId, petAttackerTargetIds: eligible.filter((mob) => mob.targetActorId === summon.id).map((mob) => mob.id), ownerAttackerTargetIds: eligible.filter((mob) => mob.targetActorId === summon.ownerActorId).map((mob) => mob.id), ownerTargetId, ambientTargetIds: eligible.filter((mob) => { const mobPosition = getMobPosition(mob.id); return mobPosition && distanceSquared(summon.position, mobPosition) <= Math.max(5, mob.aggroRange) ** 2; }).map((mob) => mob.id), petAtStayPosition, isReturning: summon.hunterPetIsReturning, isCommandFollow: summon.hunterPetIsCommandFollow, isFollowing: summon.hunterPetIsFollowing, inMeleeRangeTargetIds, }); summon = { ...summon, hunterPetPendingOwnerAttackTargetId: null }; } const targetId = hunterPetDecision ? hunterPetDecision.targetId : summon.targetId && work.mobs[summon.targetId] && !work.mobs[summon.targetId].dead ? summon.targetId : state.selectedTargetId && work.mobs[state.selectedTargetId] && !work.mobs[state.selectedTargetId].dead ? state.selectedTargetId : Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null; const catProwl = summon.hunterPet?.prowl; let catProwlActive = Boolean(catProwl && work.auras.some((aura) => ( aura.targetId === summon.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_PROWL_TAG) && (aura.expiresAt === null || aura.expiresAt > now) ))); const targetIsEngaged = Boolean(targetId && work.mobs[targetId]?.engaged); const prowlFocus = summon.resources.focus ?? normalizeActorResourcePool("focus", 100, 100, 5, now); if (catProwl && !catProwlActive && !targetIsEngaged && (summon.hunterPetProwlReadyAt ?? 0) <= now && prowlFocus.current >= catProwl.focusCost) { work.auras = [...applyCombatAura(work.auras, { definition: wow335HunterPetProwlAura(catProwl), sourceId: summon.id, targetId: summon.id, now, }).auras]; summon = { ...summon, resources: { ...summon.resources, focus: { ...prowlFocus, current: Math.max(0, prowlFocus.current - catProwl.focusCost), lastSpentAt: catProwl.focusCost > 0 ? now : prowlFocus.lastSpentAt, }, }, hunterPetProwlReadyAt: now + Math.max(catProwl.cooldownMs, catProwl.categoryCooldownMs), }; catProwlActive = true; } const petMovementMultiplier = catProwlActive && catProwl ? Math.max(0, 1 - catProwl.movementSpeedReductionPercent / 100) : 1; if (!targetId) { let position = summon.position; let isReturning = Boolean(summon.hunterPetIsReturning); let isFollowing = Boolean(summon.hunterPetIsFollowing); const ownerPosition = summon.ownerActorId === PLAYER_AGGRO_ID ? state.playerPosition : getPartyRuntimePosition(summon.ownerActorId); const returnTarget = hunterPetDecision?.movement === "return-to-stay" ? summon.hunterPetStayPosition : hunterPetDecision?.movement === "follow-owner" ? ownerPosition : null; if (returnTarget) { const dx = returnTarget[0] - position[0]; const dz = returnTarget[2] - position[2]; const length = Math.hypot(dx, dz); const stopDistance = hunterPetDecision?.movement === "follow-owner" ? 3 : 0.5; if (length > stopDistance) { const step = Math.min(length - stopDistance, 4 * petMovementMultiplier * delta); const requested: CombatPosition = [position[0] + dx / length * step, position[1], position[2] + dz / length * step]; position = projectCombatGroundPoint(requested, step, position) ?? position; } if (hunterPetDecision?.movement === "follow-owner") { isReturning = Math.hypot(returnTarget[0] - position[0], returnTarget[2] - position[2]) > stopDistance; isFollowing = !isReturning; } } const followStateChanged = isReturning !== Boolean(summon.hunterPetIsReturning) || isFollowing !== Boolean(summon.hunterPetIsFollowing); return readyToAct || position !== summon.position || followStateChanged ? { ...summon, targetId: null, position, hunterPetIsReturning: isReturning, hunterPetIsFollowing: isFollowing, nextActionAt: readyToAct ? now + Math.max(500, summon.pulseIntervalMs) : summon.nextActionAt } : summon; } const turtleShellShield = summon.hunterPet?.shellShield; const shellShieldAuraActive = work.auras.some((aura) => ( aura.targetId === summon.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_SHELL_SHIELD_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )); if (turtleShellShield && work.mobs[targetId] && !work.mobs[targetId]!.dead && (summon.hunterPetShellShieldReadyAt ?? 0) <= now && !shellShieldAuraActive) { work.auras = [...applyCombatAura(work.auras, { definition: wow335HunterPetShellShieldAura(turtleShellShield), sourceId: summon.id, targetId: summon.id, now, }).auras]; summon = { ...summon, hunterPetShellShieldReadyAt: now + turtleShellShield.cooldownMs, }; } const furiousHowl = summon.hunterPet?.furiousHowl; const howlTarget = work.mobs[targetId]; const howlFocus = summon.resources.focus ?? normalizeActorResourcePool("focus", 100, 100, 5, now); if (furiousHowl && howlTarget?.engaged && !howlTarget.dead && (summon.hunterPetFuriousHowlReadyAt ?? 0) <= now && howlFocus.current >= furiousHowl.focusCost) { const howlAura = wow335HunterPetFuriousHowlAura(furiousHowl); const ownerAuraId = summon.ownerActorId === PLAYER_AGGRO_ID ? PLAYER_AURA_ENTITY_ID : summon.ownerActorId; for (const recipientId of [summon.id, ownerAuraId]) { work.auras = [...applyCombatAura(work.auras, { definition: howlAura, sourceId: summon.id, targetId: recipientId, now, }).auras]; } summon = { ...summon, resources: { ...summon.resources, focus: { ...howlFocus, current: Math.max(0, howlFocus.current - furiousHowl.focusCost), lastSpentAt: now, }, }, hunterPetFuriousHowlReadyAt: now + furiousHowl.cooldownMs, }; } const targetPosition = getMobPosition(targetId); let position = summon.position; if (targetPosition) { const dx = targetPosition[0] - position[0]; const dz = targetPosition[2] - position[2]; const length = Math.hypot(dx, dz); if (summon.kind !== "totem" && !(summon.hunterPet && hunterPetDecision?.movement === "hold-stay") && length > 4) { const step = Math.min(length - 3, 4 * petMovementMultiplier * delta); const requested: CombatPosition = [position[0] + dx / length * step, position[1], position[2] + dz / length * step]; position = projectCombatGroundPoint(requested, step, position) ?? position; } const inAttackRange = Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5); const hasLineOfSight = combatHasLineOfSight(position, targetPosition); const hunterPetCanAttack = !summon.hunterPet || (summon.hunterPetCommand ?? WOW335_HUNTER_PET_DEFAULT_COMMAND) !== "stay" || summon.hunterPetIsCommandAttack || Boolean(summon.hunterPetStayPosition && distanceSquared(position, summon.hunterPetStayPosition) <= 1); let catAutoAttackHandled = false; if (inAttackRange && hasLineOfSight && hunterPetCanAttack) { if (summon.hunterPet?.family === "cat" && readyToSwing) { const catPetAuras = aurasForEntity(work.auras, summon.id); const catAutoAttackDamage = Math.max(1, Math.round(resolveAuraDamageValue( summon.attackDamage, catPetAuras, { direction: "dealt", school: "physical", attackKind: "melee" }, ))); const combatEventCountBeforeAutoAttack = work.combatEvents.length; const catAutoAttackResult = damageMobInWork( state.classId, work, targetId, catAutoAttackDamage, now, { actorId: summon.ownerActorId, role: "damage", abilityId: summon.sourceAbilityId }, "physical", 0, undefined, undefined, false, summon.hunterPet.baseStats.level, ); const catAutoAttackHit = work.combatEvents.slice(combatEventCountBeforeAutoAttack).some((event) => ( event.kind === "damage" && event.sourceActorId === summon.ownerActorId && event.targetActorId === targetId && event.abilityId === summon.sourceAbilityId && event.outcome === "hit" )); if (catAutoAttackResult.amount > 0) { evaluateProcsInWork(work, { trigger: "hit", subjectId: summon.id, otherId: targetId, abilityId: summon.sourceAbilityId, amount: catAutoAttackResult.amount, school: "physical", attackKind: "melee", }, now); } if (catAutoAttackHit && summon.hunterPet.prowl?.breaksOnMeleeAutoAttack) { work.auras = work.auras.filter((aura) => !( aura.targetId === summon.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_PROWL_TAG) )); } catAutoAttackHandled = true; } if (summon.hunterPet && summon.hunterPetGrowlAutocastEnabled && (summon.hunterPetGrowlReadyAt ?? 0) <= now) { const growl = wow335HunterPetGrowlProfileForLevel(summon.hunterPet.baseStats.level); const growlFocus = summon.resources.focus ?? normalizeActorResourcePool("focus", 100, 100, 5, now); const growlTarget = work.mobs[targetId]; if (growlTarget && !growlTarget.dead && (growlTarget.navigationEvadeUntil ?? 0) <= now && growlFocus.current >= growl.focusCost) { if (!growlTarget.engaged) engageMobGroupInWork(work, targetId, now, summon.id); const currentTarget = work.mobs[targetId]; if (currentTarget) { work.mobs[targetId] = { ...currentTarget, threatByActor: addActorThreat(currentTarget.threatByActor, summon.id, growl.threatAmount), }; } summon = { ...summon, resources: { ...summon.resources, focus: { ...growlFocus, current: Math.max(0, growlFocus.current - growl.focusCost), lastSpentAt: now }, }, hunterPetGrowlReadyAt: now + growl.cooldownMs, }; } } if (summon.hunterPet?.family === "cat") { const catProfile = summon.hunterPet; const focus = summon.resources.focus ?? normalizeActorResourcePool("focus", 100, 100, 5, now); const rake = catProfile.rake; const castRake = Boolean(rake && (summon.hunterPetRakeReadyAt ?? 0) <= now && focus.current >= rake.focusCost); const abilityId = castRake ? rake!.abilityId : catProfile.abilityId; const focusCost = castRake ? rake!.focusCost : catProfile.focusCost; if ((summon.hunterPetSpecialReadyAt ?? 0) <= now && focus.current >= focusCost && work.mobs[targetId] && !work.mobs[targetId]!.dead) { const target = work.mobs[targetId]; const specialExecuted = Boolean(target && !target.dead && (target.navigationEvadeUntil ?? 0) <= now); if (specialExecuted && catProfile.prowl?.breaksOnSpellAttack) { work.auras = work.auras.filter((aura) => !( aura.targetId === summon.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_PROWL_TAG) )); } const killCommandAura = work.auras.find((aura) => ( aura.targetId === summon.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_KILL_COMMAND_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )); const killCommandBonus = killCommandAura ? wow335HunterPetKillCommandBonusPercent(killCommandAura.stacks) : 0; const petAuras = aurasForEntity(work.auras, summon.id); const petAttackPower = resolveAuraStatValue( catProfile.effectiveStats.attackPower, petAuras, "attack-power", ); const additionalAttackPower = petAttackPower - catProfile.effectiveStats.attackPower; const baseSpecialDamage = resolveAuraDamageValue( castRake ? wow335HunterPetRakeDamage(catProfile, rake!, false, additionalAttackPower) : wow335HunterPetClawDamage(catProfile, additionalAttackPower), petAuras, { direction: "dealt", school: "physical", attackKind: "melee" }, ); const specialDamage = Math.max(1, Math.round(baseSpecialDamage * (1 + killCommandBonus / 100))); const specialResult = damageMobInWork( state.classId, work, targetId, specialDamage, now, { actorId: summon.id, role: "damage", abilityId }, "physical", 0, undefined, undefined, false, catProfile.baseStats.level, ); if (specialResult.amount > 0) { evaluateProcsInWork(work, { trigger: "hit", subjectId: summon.id, otherId: targetId, abilityId, amount: specialResult.amount, school: "physical", attackKind: "melee", }, now); } if (castRake && specialExecuted && !work.mobs[targetId]?.dead) { work.effects = [...work.effects, { ...timedEffect( "dot", rake!.abilityId, summon.id, targetId, { coefficient: 0, fixedAmount: Math.round( wow335HunterPetRakeDamage(catProfile, rake!, true, additionalAttackPower) * (1 + killCommandBonus / 100), ) }, rake!.ticks, rake!.intervalMs, now, WOW335_HUNTER_PET_RAKE_TAG, "physical", ), ignoreArmor: rake!.periodicIgnoresArmor, }]; } if (specialExecuted && killCommandAura) { work.auras = work.auras.flatMap((aura) => { if (aura.instanceId !== killCommandAura.instanceId) return [aura]; return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; }); } summon = { ...summon, resources: { ...summon.resources, focus: { ...focus, current: Math.max(0, focus.current - focusCost), lastSpentAt: now }, }, hunterPetSpecialReadyAt: now + catProfile.globalCooldownMs, ...(castRake ? { hunterPetRakeReadyAt: now + rake!.cooldownMs } : {}), }; } } else if (summon.hunterPet && (summon.hunterPetSpecialReadyAt ?? 0) <= now) { const focus = summon.resources.focus ?? normalizeActorResourcePool("focus", 100, 100, 5, now); if (focus.current >= summon.hunterPet.focusCost) { const target = work.mobs[targetId]; const specialExecuted = Boolean(target && !target.dead && (target.navigationEvadeUntil ?? 0) <= now); const killCommandAura = work.auras.find((aura) => ( aura.targetId === summon.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_KILL_COMMAND_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )); const killCommandBonus = killCommandAura ? wow335HunterPetKillCommandBonusPercent(killCommandAura.stacks) : 0; const petAuras = aurasForEntity(work.auras, summon.id); const petAttackPower = resolveAuraStatValue( summon.hunterPet.effectiveStats.attackPower, petAuras, "attack-power", ); const baseSpecialDamage = resolveAuraDamageValue( wow335HunterPetBiteDamage( summon.hunterPet, petAttackPower - summon.hunterPet.effectiveStats.attackPower, ), petAuras, { direction: "dealt", school: "physical", attackKind: "melee" }, ); const specialDamage = Math.max(1, Math.round(baseSpecialDamage * (1 + killCommandBonus / 100))); const specialResult = damageMobInWork( state.classId, work, targetId, specialDamage, now, { actorId: summon.ownerActorId, role: "damage", abilityId: summon.hunterPet.abilityId }, "physical", ); if (specialResult.amount > 0) { evaluateProcsInWork(work, { trigger: "hit", subjectId: summon.id, otherId: targetId, abilityId: summon.hunterPet.abilityId, amount: specialResult.amount, school: "physical", attackKind: "melee", }, now); } if (specialExecuted && killCommandAura) { work.auras = work.auras.flatMap((aura) => { if (aura.instanceId !== killCommandAura.instanceId) return [aura]; return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; }); } summon = { ...summon, resources: { ...summon.resources, focus: { ...focus, current: Math.max(0, focus.current - summon.hunterPet.focusCost), lastSpentAt: now }, }, hunterPetSpecialReadyAt: now + summon.hunterPet.globalCooldownMs, }; } } const petAutoAttackReady = summon.hunterPet ? readyToSwing : readyToAct; if (petAutoAttackReady && !catAutoAttackHandled) { const hunterPetAttack = Boolean(summon.hunterPet); const furiousHowlDamageBonus = summon.hunterPet?.furiousHowl && work.auras.some((aura) => ( aura.targetId === summon.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_FURIOUS_HOWL_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )) ? wow335HunterPetFuriousHowlAutoAttackDamageBonus(summon.hunterPet!.furiousHowl!) : 0; const petAttackDamage = hunterPetAttack ? Math.max(1, Math.round(resolveAuraDamageValue( summon.attackDamage, aurasForEntity(work.auras, summon.id), { direction: "dealt", school: "physical", attackKind: "melee" }, ) + furiousHowlDamageBonus)) : summon.attackDamage; const result = damageMobInWork( state.classId, work, targetId, petAttackDamage, now, { actorId: summonAbility?.name === "Shadowfiend" ? summon.id : summon.ownerActorId, role: "damage", abilityId: summon.sourceAbilityId }, summonAbility?.name === "Shadowfiend" ? "shadow" : "physical", ); if (hunterPetAttack && result.amount > 0 && summon.hunterPet?.prowl?.breaksOnMeleeAutoAttack) { work.auras = work.auras.filter((aura) => !( aura.targetId === summon.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_PET_PROWL_TAG) )); } if (hunterPetAttack && result.amount > 0) { evaluateProcsInWork(work, { trigger: "hit", subjectId: summon.id, otherId: targetId, abilityId: summon.sourceAbilityId, amount: result.amount, school: "physical", attackKind: "melee", }, now); } if (summonAbility?.name === "Shadowfiend" && result.amount > 0) { shadowfiendManaRestores.push({ ownerActorId: summon.ownerActorId, summonId: summon.id, abilityId: summon.sourceAbilityId, }); } } } } return { ...summon, targetId, position, ...(summon.hunterPet ? { hunterPetIsReturning: false, hunterPetIsFollowing: false, } : {}), nextActionAt: readyToAct ? now + Math.max(500, summon.pulseIntervalMs) : summon.nextActionAt, }; }); for (const [id, mob] of Object.entries(work.mobs)) { const statuses = mob.statuses.filter((status) => status.endsAt > now); const auras = mob.auras.filter((aura) => aura.endsAt <= 0 || aura.endsAt > now); const schoolLockedUntil = Object.fromEntries( Object.entries(mob.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now), ) as Partial>; const corpseExpired = mob.dead && !mob.despawned && mob.corpseDespawnAt !== null && now >= mob.corpseDespawnAt; if ( statuses.length !== mob.statuses.length || auras.length !== mob.auras.length || Object.keys(schoolLockedUntil).length !== Object.keys(mob.schoolLockedUntil).length || corpseExpired ) { work.mobs[id] = { ...mob, statuses, auras, schoolLockedUntil, activeCast: mob.dead ? null : mob.activeCast, despawned: mob.despawned || corpseExpired, }; } } const activeSummonIds = new Set(work.summons.map((summon) => summon.id)); for (const [actorId, actor] of Object.entries(work.actors)) { if (["pet", "totem", "summon"].includes(actor.kind) && !activeSummonIds.has(actorId)) { delete work.actors[actorId]; } } for (const summon of work.summons) { work.actors[summon.id] = combatActorFromSummon(summon, progression.level, work.auras); } const levelled = progression.levelsGained > 0; if (autoAttackTargetId && work.mobs[autoAttackTargetId]?.dead) { autoAttackTargetId = null; nextAutoAttackAt = 0; nextOffhandAutoAttackAt = 0; } if (work.health <= 0 || (autoShotTargetId && work.mobs[autoShotTargetId]?.dead)) { autoShotTargetId = null; nextAutoShotAt = 0; } return { resource: clamp(work.resource, 0, work.maxResource), maxResource: work.maxResource, resourcePools: { ...work.resourcePools, [resourceProfile.type]: clamp(work.resource, 0, work.maxResource) }, romNativeRecoveryClock: work.health <= 0 ? undefined : recoveryClockAdvance.clock, warriorAngerManagementRageRemainderMs, maxResourcePools: work.maxResourcePools, lastResourceSpentAt: work.lastResourceSpentAt, deathKnightRunes: work.deathKnightRunes, talentModifiers: work.talentModifiers, shield: work.shield, shieldExpiresAt: work.shieldExpiresAt, health: progression.levelsGained > 0 ? progression.health : work.health, maxHealth: progression.maxHealth, controlledUntil: state.controlledUntil > now ? state.controlledUntil : 0, controlMechanic: state.controlledUntil > now ? state.controlMechanic : null, level: progression.level, xp: progression.xp, xpToNext: progression.xpToNext, mobs: work.mobs, auras: work.auras, effects: work.effects.length === state.effects.length && work.effects.every((effect, index) => effect === state.effects[index]) ? state.effects : work.effects, summons: work.summons, areaEffects: work.areaEffects, combatEvents: work.combatEvents, romMageDruidGreenGuardianStationaryProgress: greenGuardianProgress, actors: work.actors, cooldowns: work.cooldowns, schoolLockedUntil: Object.fromEntries( Object.entries(state.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now), ), lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult, selectedTargetId: state.selectedTargetId && work.mobs[state.selectedTargetId]?.dead ? null : state.selectedTargetId, selectedFriendlyActorId: state.selectedFriendlyActorId && !work.actors[state.selectedFriendlyActorId] ? null : state.selectedFriendlyActorId, autoAttackTargetId, nextAutoAttackAt, nextOffhandAutoAttackAt, autoShotTargetId, nextAutoShotAt, playerAnimationEvent: autoAttackEvent ?? state.playerAnimationEvent, comboPoints: state.comboTargetId && work.mobs[state.comboTargetId]?.dead ? 0 : work.comboPoints, comboTargetId: state.comboTargetId && work.mobs[state.comboTargetId]?.dead ? null : work.comboTargetId, feedback: levelled ? nextFeedback("level-up", levelUpMessage(state.classId, state.level, progression.level)) : work.combatResults.length > 0 && work.combatResults.at(-1)?.outcome !== "hit" ? nextFeedback("damage", `Attack ${combatResultDescription(work.combatResults.at(-1)!)}.`, { amount: work.combatResults.at(-1)!.finalDamage, }) : state.feedback, }; }); for (const restore of shadowfiendManaRestores) { get().restoreActorResource( restore.ownerActorId, "mana", 5, true, restore.summonId, restore.abilityId, now, ); } for (const tick of partySelfDrainTicks) { const damaged = usePartyStore.getState().damageMemberResolved(tick.memberId, tick.amount); if (damaged > 0) { const member = usePartyStore.getState().members.find((candidate) => candidate.id === tick.memberId); set((state) => ({ actors: member && state.actors[tick.memberId] ? { ...state.actors, [tick.memberId]: companionActorWithMemberRuntime(state.actors[tick.memberId], member), } : state.actors, combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "damage", sourceActorId: tick.memberId, targetActorId: tick.memberId, abilityId: tick.sourceAbilityId, rawAmount: damaged, effectiveAmount: damaged, overheal: 0, absorbed: 0, critical: false, }), })); } } for (const tick of partyHotTicks) { const healed = get().healActor(tick.memberId, tick.amount, { sourceActorId: tick.sourceActorId, abilityId: tick.sourceAbilityId, amountAlreadyModified: true, now, }).effectiveAmount; if (healed > 0) { get().addHealingThreat( threatSourceForActor(get(), tick.sourceActorId, tick.sourceAbilityId, true), healed, ); } } const cast = get().activeCast; if (cast?.mode === "channel") { const catalogAbility = abilityById(cast.abilityId); const ability = catalogAbility ? learnedAbilityForBuild(catalogAbility, get().level, get().talentRanks) : null; let ticksCompleted = cast.ticksCompleted; let nextTickAt = cast.nextTickAt; let romPriestHolyCandleCharged = cast.romPriestHolyCandleCharged ?? false; while (ability && ticksCompleted < cast.totalTicks && nextTickAt <= now && nextTickAt <= cast.completesAt) { const channelTargeting = targetingRulesForAbility(ability); const groundChannel = channelTargeting.shape === "ground"; const target = cast.targetId ? get().mobs[cast.targetId] : null; const friendlyTarget = cast.targetId ? usePartyStore.getState().members.find((member) => member.id === cast.targetId) : null; const targetPosition = cast.targetId ? getMobPosition(cast.targetId) ?? getPartyRuntimePosition(cast.targetId) : get().playerPosition; const channelHostile = ability.target === "hostile" || (ability.target === "any" && Boolean(target)); const distance = groundChannel ? 0 : cast.targetId ? channelHostile ? Math.sqrt(mobRuntimeDistanceSquared(cast.targetId, cast.origin)) : Math.sqrt(partyRuntimeDistanceSquared(cast.targetId, cast.origin)) : 0; const minimumRange = ability ? talentAdjustedRange(ability.range.min, ability, get().talentModifiers) : 0; const maximumRange = ability ? talentAdjustedCastRange(ability.range.max, ability, get().talentModifiers) : 0; const targetUnavailable = groundChannel ? !cast.groundPosition : channelHostile ? !target || target.dead || !targetPosition : (ability.target === "friendly" || ability.target === "any") && cast.targetId ? !friendlyTarget || friendlyTarget.health <= 0 || !targetPosition : false; if (targetUnavailable || distance < minimumRange || (cast.targetId !== null && distance > maximumRange)) { get().cancelCast((target?.dead || friendlyTarget?.health === 0) ? "Channel ended: the target was defeated." : "Channel interrupted: target unavailable.", now); return; } const channelEffects = channelAmountEffects(ability, cast.totalTicks); const damageAmount = channelEffects.reduce( (total, { effect, multiplier }) => total + (effect.kind === "damage" || effect.kind === "dot" ? equipmentAdjustedCombatAmount( effect, get().level, get().gearStats, get().classId, ability, 0, undefined, get().raceId, get().gender, get().statRuleset, get().secondaryClassId, auraAdjustedCharacterStats(deriveCharacterStats(get().classId, get().level, get().gearStats, get().raceId, {}, get().secondaryClassId, get().gender, get().statRuleset), get().auras, PLAYER_AURA_ENTITY_ID, now).romNative, undefined, activeEnemyDisarmChannels(get().auras, PLAYER_AURA_ENTITY_ID, now), ) * multiplier : 0), 0, ); const healingAmount = channelEffects.reduce( (total, { effect, multiplier }) => total + (effect.kind === "heal" || effect.kind === "hot" ? equipmentAdjustedCombatAmount( effect, get().level, get().gearStats, get().classId, ability, 0, undefined, get().raceId, get().gender, get().statRuleset, get().secondaryClassId, ) * multiplier : 0), 0, ); if (damageAmount > 0) { const channelCenter = groundChannel ? cast.groundPosition! : channelHostile ? targetPosition ?? cast.origin : get().playerPosition; const channelRadius = groundChannel ? channelTargeting.radius ?? ability.radius ?? 8 : ability.radius; const channelTargets = channelRadius === undefined ? (cast.targetId && channelHostile ? [cast.targetId] : []) : nearbyMobRuntimeIds(channelCenter, talentAdjustedRange(channelRadius, ability, get().talentModifiers)) .filter((id) => !get().mobs[id]?.dead && Boolean(getMobPosition(id) && combatHasLineOfSight(channelCenter, getMobPosition(id)!))); for (const channelTargetId of channelTargets) { const latest = get(); const channelTarget = latest.mobs[channelTargetId]; const mageTargetAmount = latest.classId === "mage" && channelTarget ? wow335MageTormentTheWeakDamage( damageAmount, latest.talentRanks[WOW335_MAGE_TORMENT_THE_WEAK_TALENT_ID] ?? 0, ability.name, channelTarget.statuses, nextTickAt, ) : damageAmount; const maelstromWeaponPvpDamageReductionPercent = channelTarget?.characterDefender && abilityAttackKind(latest.classId, ability) === "spell" ? wow335ShamanMaelstromWeaponPvpDamageReductionPercent( cast.maelstromWeaponRank ?? 0, cast.maelstromWeaponStacksConsumed ?? 0, ability.name, ) : 0; get().damageMob( channelTargetId, talentAdjustedAmount( mageTargetAmount, latest.talentModifiers, "damage-percent", ability, ) * (1 - maelstromWeaponPvpDamageReductionPercent / 100), nextTickAt, playerThreatSource(get(), ability.id, true), abilityDamageSchool(get().classId, ability), { attackKind: abilityAttackKind(get().classId, ability), seed: `${PLAYER_AGGRO_ID}:${ability.id}:${channelTargetId}:${nextTickAt}:channel:${ticksCompleted}`, abilityId: ability.id, periodic: true, rules: { canMiss: false, canCrit: (cast.arcanePotencyCriticalChanceBonusPercent ?? 0) > 0, canDodge: false, canParry: false, canBlock: false, }, criticalChanceBonusPercent: cast.arcanePotencyCriticalChanceBonusPercent ?? 0, }, ); } } if (healingAmount > 0 && !(ability.target === "any" && channelHostile)) { if (!romPriestHolyCandleCharged && get().classId === "rom-priest" && get().secondaryClassId === null) { romPriestHolyCandleCharged = get().triggerRomPriestHolyCandle( PLAYER_AURA_ENTITY_ID, ability.id, nextTickAt, ) > 0; } const perTargetHealing = talentAdjustedAmount( healingAmount, get().talentModifiers, "healing-percent", ability, ); let healed = 0; if (!channelHostile && (ability.target === "friendly" || ability.target === "any") && cast.targetId) { const targetMember = usePartyStore.getState().members.find((member) => member.id === cast.targetId); if (targetMember) { const current = get(); const actor = companionActorFromMember( targetMember, entityAbsorbAmount(current.auras, targetMember.id), current.actors[targetMember.id], ); const effective = actor.alive ? Math.min(actor.maxHealth - actor.health, Math.max(0, Math.round(perTargetHealing))) : 0; if (effective > 0) { const nextActor = { ...actor, health: actor.health + effective }; set({ actors: { ...current.actors, [actor.id]: nextActor } }); mirrorCompanionActor(nextActor); healed += effective; } } } else if (ability.name === "Divine Hymn") { const origin = cast.origin; const candidates = [ { id: PLAYER_AURA_ENTITY_ID, health: get().health, maxHealth: get().maxHealth }, ...usePartyStore.getState().members.flatMap((member) => { const position = getPartyRuntimePosition(member.id); const current = get(); const actor = companionActorFromMember(member, entityAbsorbAmount(current.auras, member.id), current.actors[member.id]); return position && actor?.alive && actor.health > 0 && Math.sqrt(distanceSquared(origin, position)) <= ability.radius! && combatHasLineOfSight(origin, position) ? [{ id: member.id, health: actor.health, maxHealth: actor.maxHealth }] : []; }), ].filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth) .sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth || left.id.localeCompare(right.id)) .slice(0, 3); for (const candidate of candidates) { if (candidate.id === PLAYER_AURA_ENTITY_ID) healed += get().healPlayer(perTargetHealing); else { const current = get(); const member = usePartyStore.getState().members.find((entry) => entry.id === candidate.id); const actor = member ? companionActorFromMember(member, entityAbsorbAmount(current.auras, member.id), current.actors[member.id]) : null; if (!actor?.alive) continue; const effective = Math.min(actor.maxHealth - actor.health, Math.max(0, Math.round(perTargetHealing))); if (effective > 0) { const nextActor = { ...actor, health: actor.health + effective }; set({ actors: { ...current.actors, [actor.id]: nextActor } }); mirrorCompanionActor(nextActor); healed += effective; } } get().applyActorAura(PLAYER_AURA_ENTITY_ID, candidate.id, 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, }, nextTickAt); } } else { healed += get().healPlayer(perTargetHealing); if (ability.radius !== undefined) { for (const member of usePartyStore.getState().members) { const current = get(); const actor = companionActorFromMember( member, entityAbsorbAmount(current.auras, member.id), current.actors[member.id], ); const effective = actor.alive ? Math.min(actor.maxHealth - actor.health, Math.max(0, Math.round(perTargetHealing))) : 0; if (effective <= 0) continue; const nextActor = { ...actor, health: actor.health + effective }; set({ actors: { ...current.actors, [actor.id]: nextActor } }); mirrorCompanionActor(nextActor); healed += effective; } } } if (healed > 0) get().addHealingThreat(playerThreatSource(get(), ability.id, true), healed); } const drainedResource = ability.effects.reduce((total, effect) => ( effect.kind === "resource-drain" && !effect.burn ? total + effect.amount : total ), 0) / cast.totalTicks; if (drainedResource > 0) { const activeResource = resourceProfileForClass(get().classId).type; const maximum = get().maxResourcePools[activeResource] ?? get().maxResource; const amount = ability.effects.some((effect) => effect.kind === "resource-drain" && effect.percentage) ? maximum * drainedResource / 100 : drainedResource; const pools = { ...get().resourcePools }; pools[activeResource] = clamp((pools[activeResource] ?? get().resource) + amount, 0, maximum); set({ resource: pools[activeResource]!, resourcePools: pools }); } emitPlayerAbilityPresentation( ability, "tick", nextTickAt, get().playerPosition, cast.targetId, cast.groundPosition ?? (cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition), false, channelHostile, ); ticksCompleted += 1; nextTickAt = channelPulseAt(ability, cast.startedAt, cast.originalDurationMs, ticksCompleted); } if (get().activeCast?.id !== cast.id) return; if (ticksCompleted >= cast.totalTicks || now >= cast.completesAt) { if (ability) get().castAbility(cast.abilityId, cast.origin, now, cast); else set({ activeCast: null }); } else if (ticksCompleted !== cast.ticksCompleted) { set({ activeCast: { ...cast, ticksCompleted, nextTickAt, ...(romPriestHolyCandleCharged ? { romPriestHolyCandleCharged: true } : {}), } }); } return; } if (cast && now >= cast.completesAt) { set({ activeCast: null }); get().castAbility(cast.abilityId, cast.origin, now, cast); } }, resetEncounter: (options = {}) => set((state) => { resetCombatPresentationSession(); const preserveDefeatedIds = new Set(options.preserveDefeatedIds ?? []); for (const mob of Object.values(state.mobs)) { if (mob.summonedBy) removeMobPosition(mob.id); } const mobs = Object.fromEntries(Object.entries(state.mobs) .filter(([, mob]) => !mob.summonedBy) .map(([id, mob]) => { const preserveDefeated = mob.dead && ( options.preserveDefeated === true || preserveDefeatedIds.has(id) ); return [id, { ...mob, health: preserveDefeated ? 0 : mob.maxHealth, dead: preserveDefeated, pendingLoot: preserveDefeated ? mob.pendingLoot : [], bossCardDrop: preserveDefeated ? mob.bossCardDrop : undefined, looted: preserveDefeated ? mob.looted : false, despawned: preserveDefeated ? mob.despawned : false, diedAt: preserveDefeated ? mob.diedAt : null, corpseDespawnAt: preserveDefeated ? mob.corpseDespawnAt : null, experienceGranted: preserveDefeated ? mob.experienceGranted : false, statuses: [], controlDiminishingReturns: {}, tauntDiminishingReturns: { applications: 0, resetAt: 0 }, auras: [], threatByActor: {}, targetActorId: null, forcedTarget: null, lastDamageAt: 0, engaged: false, combatPhase: "idle" as const, homePosition: null, nextAttackAt: 0, ...resetEnemyWeaponAttackState(mob), abilityReadyAt: {}, abilityCastCounts: {}, meleeAttackSpeedMultiplier: 1, activeCast: null, schoolLockedUntil: {}, nextWoundAnimationAt: 0, }] as const; })); const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()); const maxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, state.talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, auras, state.gender, state.statRuleset, ); return { health: maxHealth, maxHealth, controlledUntil: 0, controlMechanic: null, shield: 0, shieldExpiresAt: 0, resource: initialResourceForMaximum(state.classId, state.maxResource), resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [ type, type === resourceProfileForClass(state.classId).type ? initialResourceForMaximum(state.classId, maximum ?? state.maxResource) : 0, ])), lastResourceSpentAt: {}, romNativeRecoveryClock: undefined, comboPoints: 0, comboTargetId: null, deathKnightRunes: state.classId === "death-knight" ? createDeathKnightRunes() : [], cooldowns: {}, globalCooldownEndsAt: 0, activeCast: null, schoolLockedUntil: {}, playerAnimationEvent: nextAnimationEvent("ability-cancel"), autoAttackTargetId: null, nextAutoAttackAt: 0, nextOffhandAutoAttackAt: 0, autoShotTargetId: null, nextAutoShotAt: 0, mobs, selectedTargetId: null, selectedFriendlyActorId: null, effects: [], enemyPeriodicEffects: [], killingSpreeWindows: [], auras, summons: [], areaEffects: [], pendingGroundTarget: null, combatEvents: [], actors: {}, lastCombatResult: null, feedback: nextFeedback( "success", options.preserveDefeated || preserveDefeatedIds.size > 0 ? "The current pull has been reset; defeated enemies remain cleared." : "The encounter has been reset.", ), }; }), setGlyphActive: (glyphId, active) => { const state = get(); if (state.classId !== "druid" || glyphId !== WOW335_GLYPH_OF_SWIFTMEND_ID) return false; const glyphIds = active ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : state.glyphIds.filter((id) => id !== WOW335_GLYPH_OF_SWIFTMEND_ID); if (glyphIds.length !== state.glyphIds.length) set({ glyphIds }); return true; }, allocateTalent: (treeId) => { const state = get(); const result = allocateTalentPoint(state.talentRanks, state.classId, treeId, state.level); if (!result.ok) { const message = result.reason === "no-points" ? "No talent points are available." : result.reason === "ability-essence" ? "Not enough Ability Essence." : result.reason === "talent-essence" ? "Not enough Talent Essence." : result.reason === "point-gate" ? "Invest the required Ability and Talent Essence in this tab first." : result.reason === "level-locked" ? "That node requires a higher level." : result.reason === "max-rank" ? "That talent is already at maximum rank." : result.reason === "tier-locked" ? "Spend five points per earlier tier in this tree first." : result.reason === "prerequisite" ? "The prerequisite talent must be completed first." : "That talent is unavailable."; set({ feedback: nextFeedback("error", message) }); return false; } const talentModifiers = characterStatModifiers(state.classId, state.level, result.ranks); const auras = syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()); const maxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, auras, state.gender, state.statRuleset, ); const maxResourceBase = talentAdjustedMaxResource( state.classId, state.level, talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, ); const nativeManaMaximum = nativeManaPoolMaximum( state.classId, state.level, talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, auras, ); const maxResource = nativeManaMaximum !== null && resourceProfileForClass(state.classId).type === "mana" ? nativeManaMaximum : maxResourceBase; const resizedMana = nativeManaMaximum === null ? null : resizeManaPool(state.resourcePools, state.maxResourcePools, nativeManaMaximum, true); set({ talentRanks: result.ranks, talentModifiers, maxHealth, health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth), maxResource, ...(resizedMana ? { resourcePools: resizedMana.pools, maxResourcePools: resizedMana.maximums, } : {}), resource: resourceProfileForClass(state.classId).type === "mana" && resizedMana ? resizedMana.pools.mana ?? 0 : clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource), auras, feedback: nextFeedback("talent", "Conquest node rank allocated."), }); return true; }, resetTalents: () => set((state) => { const talentRanks = resetTalentRanks(state.talentRanks, state.classId); const talentModifiers = characterStatModifiers(state.classId, state.level, talentRanks); const auras = syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()); const maxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, auras, state.gender, state.statRuleset, ); const maxResourceBase = talentAdjustedMaxResource( state.classId, state.level, talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, ); const nativeManaMaximum = nativeManaPoolMaximum( state.classId, state.level, talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, state.gender, state.statRuleset, auras, ); const maxResource = nativeManaMaximum !== null && resourceProfileForClass(state.classId).type === "mana" ? nativeManaMaximum : maxResourceBase; const resizedMana = nativeManaMaximum === null ? null : resizeManaPool(state.resourcePools, state.maxResourcePools, nativeManaMaximum, true); return { talentRanks, talentModifiers, maxHealth, health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth), maxResource, ...(resizedMana ? { resourcePools: resizedMana.pools, maxResourcePools: resizedMana.maximums, } : {}), resource: resourceProfileForClass(state.classId).type === "mana" && resizedMana ? resizedMana.pools.mana ?? 0 : clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource), auras, actionBindings: withoutUnlearnedTalentBindings(state.actionBindings, state.abilities, talentRanks), feedback: nextFeedback("talent", "Talent points reset."), }; }), setActionBinding: (layer, control, abilityId) => { const state = get(); if (abilityId !== null && !state.abilities.some((ability) => ( ability.id === abilityId && !ability.passive && (!abilityTalentNodeId(ability) || Boolean(learnedAbilityRank(ability, state.talentRanks))) ))) return false; set({ actionBindings: withActionBinding(state.actionBindings, layer, control, abilityId) }); return true; }, resetActionBindingLayer: (layer) => set((state) => ({ actionBindings: restoreActionBindingLayer( state.actionBindings, layer, defaultActionBarForCharacter(state.classId, state.secondaryClassId, state.level), ), })), resetActionBindings: () => set((state) => ({ actionBindings: defaultActionBindings( defaultActionBarForCharacter(state.classId, state.secondaryClassId, state.level), ), })), updateSettings: (patch) => set((state) => { const settings = normalizedSettings(state.settings, patch); return gameplaySettingsEqual(settings, state.settings) ? state : { settings, auras: recoveryAuras({ ...state, settings }, combatNow()) }; }), resetSettings: () => set((state) => ( gameplaySettingsEqual(state.settings, DEFAULT_GAMEPLAY_SETTINGS) ? state : { settings: DEFAULT_GAMEPLAY_SETTINGS, auras: recoveryAuras({ ...state, settings: DEFAULT_GAMEPLAY_SETTINGS }, combatNow()) } )), })); export function selectedMob(state: CombatState): MobCombatState | null { return state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null; } export function cooldownRemainingMs( state: Pick, abilityId: string, now = combatNow(), ): number { return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now); } export function globalCooldownRemainingMs( state: Pick, now = combatNow(), ): number { return Math.max(0, state.globalCooldownEndsAt - now); } let collectingBossLoot = false; useCombatStore.subscribe((state) => { if (collectingBossLoot) return; if (useGameStore.getState().gameMode === "manastorm") return; const lootableBosses = Object.values(state.mobs).filter((mob) => ( mob.boss && mob.dead && mob.hasLoot && !mob.looted && !mob.despawned )); if (!lootableBosses.length) return; collectingBossLoot = true; try { for (const boss of lootableBosses) { useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? combatNow()); } } finally { collectingBossLoot = false; } }); export function combatPercent(current: number, maximum: number): number { if (maximum <= 0) return 0; return clamp((current / maximum) * 100, 0, 100); }