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