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 { MobCombatState } from "./combatStore"; import { abilityDamageSchool, refreshPriestPainAndSufferingDot, stableCombatUnit, useCombatStore } from "./combatStore"; import { raceAffixHasteMultiplier } from "./dungeonRaceAffixBuffs"; import { activeRaceOrbId } from "./dungeonRaceAffixRuntime"; import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority"; import type { MobVector3 } from "./mobPopulation"; 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 } from "../app/characterCatalog"; import { useGameStore } from "./store"; import { useManastormStore } from "./manastormStore"; import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog"; import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime"; import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro"; import { applyAura as applyCombatAura, resolveStatValue as resolveAuraStatValue, type ActiveAura, type DispelCategory, } from "./combatAuras"; import { ROM_ROGUE_POISON_AURA_TAG, ROM_ROGUE_POISON_SKILL_ID, romRoguePoisonApplicationForAttack, } from "./romRoguePoisonRuntime"; import { ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID, romRogueChampionNerveGearEffect, romRogueChampionNerveGearShouldTrigger, } from "./romRogueChampionNerveGearRuntime"; import { equipmentAttackPower, hastedDuration, ratingPercent, 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, partyAbilityWithHunterLockAndLoad, partyHunterLockAndLoadReady, partyAbilityAmount, partyAbilityCosts, } from "./partyAbilityAi"; import { minimumHostileTargetsForAbility } from "./combatRotation"; import { emitRotationDecision } from "./rotationTelemetry"; import { imminentCooldownMechanic } from "./companionCooldownPolicy"; import { normalizeActorResourcePool } from "./combatActors"; import { enemyControlPreventsActions } from "./enemyAttacks"; import { advanceCombatResourcePool } from "./combatResources"; import { wow335DeathKnightSuddenDoomTriggerForLandedHit } from "./wow335DeathKnightSuddenDoomRuntime"; import { WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID, wow335DeathKnightScentOfBloodOnMeleeHit, } from "./wow335DeathKnightScentOfBloodRuntime"; import { WOW335_MAGE_ARCANE_CONCENTRATION_AURA, WOW335_MAGE_ARCANE_CONCENTRATION_TAG, } from "./wow335MageTalentRuntime"; 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_EMPOWERED_FIRE_TALENT_ID, wow335MageEmpoweredFireEffect, } from "./wow335MageEmpoweredFireRuntime"; import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts"; 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 { wow335RogueImprovedKickAuraEffectForRank } from "./wow335RogueTalentRuntime"; 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 { WARLOCK_BACKDRAFT_READY_TAG, warlockBackdraftDestructionSpellEligible, warlockBackdraftEffectsForRank, warlockBackdraftTriggerEligible, } from "./wow335WarlockTalentRuntime"; import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics"; import { romCreatureFamilyTargetTags } from "./romCreatureFamilies"; import { romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides"; 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_PRIEST_MAGE_ENLIGHTENMENT, ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT, romPriestMageEnlightenmentProc, romPriestMageEnlightenmentReadyAt, } from "./romPriestMageRuntime"; 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, }; } 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 partyShamanTidalWavesBonusHealing(member: PartyMember, ability: AbilityDefinition, now: number): number { if (member.classId !== "shaman" || ability.source === "runewaker" || !["Healing Wave", "Lesser Healing Wave"].includes(ability.name)) return 0; const rank = member.talentRanks["talent-2063"] ?? 0; const bonusPercent = wow335ShamanTidalWavesBonusHealingPercent(rank); if (bonusPercent <= 0) return 0; const auras = useCombatStore.getState().auras.filter((aura) => ( aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) )); const resolvedBonusHealing = resolveAuraStatValue(member.gearStats.spellPower, auras, "spell-power"); return Math.round(Math.max(0, resolvedBonusHealing) * bonusPercent); } function partyShamanRiptideHotOnTarget(memberId: string, targetId: string, now: number) { return useCombatStore.getState().effects.find((effect) => effect.kind === "hot" && effect.sourceActorId === memberId && effect.sourceAbilityId === PARTY_SHAMAN_RIPTIDE_ABILITY_ID && effect.targetId === targetId && effect.remainingTicks > 0 && effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs > now); } function partySwiftmendHotOnTarget(memberId: string, targetId: string, now: number) { const candidates = useCombatStore.getState().effects.filter((effect) => { if (effect.kind !== "hot" || (effect.targetId ?? PLAYER_AGGRO_ID) !== targetId || effect.remainingTicks <= 0 || effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs <= now) return false; const name = abilityById(effect.sourceAbilityId)?.name; return name === "Rejuvenation" || name === "Regrowth"; }); const own = candidates.filter((effect) => effect.sourceActorId === memberId); const pool = own.length ? own : candidates; return pool.reduce((shortest, candidate) => { if (!shortest) return candidate; const expiry = candidate.nextTickAt + Math.max(0, candidate.remainingTicks - 1) * candidate.intervalMs; const shortestExpiry = shortest.nextTickAt + Math.max(0, shortest.remainingTicks - 1) * shortest.intervalMs; return expiry < shortestExpiry ? candidate : shortest; }, undefined); } function maybeProcPartyShamanImprovedWaterShield( member: PartyMember, ability: AbilityDefinition, targetId: string, school: CombatPresentationSchool, critical: boolean, now: number, ): PartyMember { if (member.classId !== "shaman" || ability.source === "runewaker" || !wow335ShamanImprovedWaterShieldShouldProc( member.talentRanks["talent-583"] ?? 0, ability.name, school, critical, stablePartyUnit(`${member.id}:${ability.id}:${targetId}:${now}:wow335-shaman-improved-water-shield`), )) return member; const requested = wow335ShamanWaterShieldOrbManaAtLevel(member.level); 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 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["talent-206"] ?? 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; } 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 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 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 partyWarlockAbilityAmount( member: PartyMember, ability: AbilityDefinition, effect: AbilityEffect, poisonDoses = 0, ): number { const amount = partyAbilityAmount(member, ability, effect, poisonDoses); 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)); } let lastAdvanceAt = 0; let nextRegenAt = 0; const nextWeaponAttackAt = new Map(); const pendingInnerFocus = new Set(); export interface PartyPresentationIdentity { readonly spellId: number; readonly name: string; readonly school: CombatPresentationSchool; readonly delivery: CombatPresentationDelivery; } function partyAttackPresentation(member: PartyMember): PartyPresentationIdentity { if (!partyUsesRangedAttacks(member)) { const spellId = member.classId === "rogue" ? 1752 : member.classId === "death-knight" ? 45462 : member.classId === "paladin" ? 20271 : 78; return { spellId, name: "Weapon Attack", school: "physical", delivery: "melee" }; } if (member.classId === "hunter") return { spellId: 56641, name: "Steady Shot", school: "physical", delivery: "projectile" }; if (member.classId === "mage") return { spellId: 116, name: "Frostbolt", school: "frost", delivery: "projectile" }; if (member.classId === "warlock") return { spellId: 686, name: "Shadow Bolt", school: "shadow", delivery: "projectile" }; if (member.classId === "priest") return { spellId: 585, name: "Smite", school: "holy", delivery: "projectile" }; if (member.classId === "druid") return { spellId: 5176, name: "Wrath", school: "nature", delivery: "projectile" }; return { spellId: 403, name: "Lightning Bolt", school: "nature", delivery: "projectile" }; } function partyHealPresentation(member: PartyMember): PartyPresentationIdentity { if (member.classId === "paladin") return { spellId: 635, name: "Holy Light", school: "holy", delivery: "projectile" }; if (member.classId === "shaman") return { spellId: 331, name: "Healing Wave", school: "nature", delivery: "projectile" }; if (member.classId === "druid") return { spellId: 5185, name: "Healing Touch", school: "nature", delivery: "projectile" }; return { spellId: 2061, name: "Flash Heal", school: "holy", delivery: "projectile" }; } function partyTauntSpellId(member: PartyMember): number { if (member.classId === "paladin") return 62124; if (member.classId === "death-knight") return 56222; if (member.classId === "druid") return 6795; return 355; } export interface PartyBossObjective { readonly id: string; readonly name: string; readonly position: MobVector3; } export function activePartyBossObjectives(): readonly PartyBossObjective[] { if (useGameStore.getState().gameMode === "manastorm") { const encounter = useManastormStore.getState().currentEncounter; return encounter ? [{ id: encounter.bossRuntimeId, name: encounter.bossName, position: encounter.bossPosition, }] : []; } const game = useGameStore.getState(); const simulation = useDungeonSimulationStore.getState(); if ( simulation.sessionMode === "spectate-live" && simulation.mode === "navigation-only" && simulation.objectiveId ) { const objective = requireDungeonDefinition(game.activeDungeonId).objectiveOrder .find((candidate) => candidate.id === simulation.objectiveId); return objective ? [objective] : []; } const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId)); const mobs = useCombatStore.getState().mobs; const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder .filter((objective) => !objective.optional && !completed.has(objective.id)); const wailingPhase = game.activeDungeonId === "wailing-caverns" ? useWailingEncounterStore.getState().phase : null; return objectives.filter((objective) => ( !objective.eventOnly || Boolean(mobs[objective.id]) || (objective.id === "mutanus-spawn" && (wailingPhase === "mutanus" || wailingPhase === "complete")) )); } function simulatedPartyExcludesPlayer(): boolean { return useDungeonSimulationStore.getState().sessionMode !== "play"; } export function resetPartyRuntimeClock(): void { lastAdvanceAt = 0; nextRegenAt = 0; nextWeaponAttackAt.clear(); pendingInnerFocus.clear(); } export function activePartyTargetId( mobs: Readonly>, selectedTargetId: string | null, ): string | null { const selected = selectedTargetId ? mobs[selectedTargetId] : null; if (selected?.engaged && !selected.dead) return selected.id; return Object.values(mobs) .filter((mob) => mob.engaged && !mob.dead) .sort((left, right) => Number(right.boss) - Number(left.boss) || left.id.localeCompare(right.id))[0]?.id ?? null; } export function partyAttackDamage( member: Pick & Partial>, ): 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 weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null; const equipmentPower = 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 = ratingPercent( gear.criticalStrikeRating, kind === "spell" ? "spellCrit" : kind === "ranged" ? "rangedCrit" : "meleeCrit", member.level, classId, ); const hit = 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 partyUsesRangedAttacks( member: Pick & Partial>, ): boolean { if (member.role === "tank") return false; if (member.role === "healer") return true; const definition = classById(member.classId); if (definition.mode !== "classic") { return (baseClassFor(member.classId) === "hunter" || ["intellect", "wisdom", "spirit"].includes(definition.primaryStat)) && partyCatalogAttackRange(member) > 5; } return ["hunter", "mage", "priest", "warlock"].includes(member.classId) || member.specialization === "Elemental" || member.specialization === "Balance"; } function partyCatalogAttackRange(member: Pick & Partial>): number { return Math.max(0, ...abilitiesForClass(member.classId) .filter((ability) => !ability.passive) .map((ability) => learnedAbilityForBuild(ability, member.level ?? 80, member.talentRanks ?? {})) .filter((ability): ability is AbilityDefinition => ability !== null) .filter((ability) => ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind))) .map((ability) => ability.range.max)); } export function partyAttackRange(member: Pick): number { if (member.role === "tank") return 3.8; if (partyUsesRangedAttacks(member)) return 19; return 4.2; } export interface PartyCombatRangeBand { readonly minimum: number; readonly preferred: number; readonly maximum: number; } const RANGED_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({ minimum: 10, preferred: 14.5, maximum: 18.1, }); const TANK_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15, }); const MELEE_DAMAGE_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({ minimum: 0, preferred: 3.3, maximum: 3.55, }); /** * Movement uses a narrower band than damage validation. Ranged attackers keep * enough room to read as ranged while melee attackers stay close to a fleeing * target instead of treating the tank's position as their own. */ export function partyCombatRangeBand( member: Pick & Partial>, ): PartyCombatRangeBand { if (partyUsesRangedAttacks(member)) { if (classById(member.classId).mode !== "classic") { const attackRange = partyCatalogAttackRange(member); if (attackRange <= 5) return RANGED_COMBAT_RANGE_BAND; const maximum = Math.min(RANGED_COMBAT_RANGE_BAND.maximum, attackRange - 0.1); const preferred = Math.min(RANGED_COMBAT_RANGE_BAND.preferred, maximum - 0.5); return { minimum: Math.min(RANGED_COMBAT_RANGE_BAND.minimum, preferred - 0.5), preferred, maximum }; } return RANGED_COMBAT_RANGE_BAND; } return member.role === "tank" ? TANK_COMBAT_RANGE_BAND : MELEE_DAMAGE_COMBAT_RANGE_BAND; } export function nextPartyBossObjective( mobs: Readonly>, ): PartyBossObjective | null { return activePartyBossObjectives().find((objective) => !mobs[objective.id]?.dead) ?? null; } function distanceSquared(left: MobVector3, right: MobVector3): number { const dx = left[0] - right[0]; const dy = left[1] - right[1]; const dz = left[2] - right[2]; return dx * dx + dy * dy + dz * dz; } function livingTargetInRange( mob: MobCombatState | null | undefined, origin: MobVector3, maximumRange: number, requireAttackCorridor = false, ): mob is MobCombatState { if (!mob || mob.dead) return false; const position = getMobPosition(mob.id); return Boolean( position && distanceSquared(position, origin) <= maximumRange * maximumRange && (!requireAttackCorridor || partyRuntimePositionAlongAttackCorridor(position)), ); } export function partyCommandTargetId( command: PartyCommand, mobs: Readonly>, selectedTargetId: string | null, currentTargetId: string | null, origin: MobVector3, acquisitionRange = ATTACK_ACQUISITION_RANGE, ): string | null { if (command === "stop") return null; const engaged = activePartyTargetId(mobs, selectedTargetId); if (engaged) return engaged; if (command === "defend") return null; const selected = selectedTargetId ? mobs[selectedTargetId] : null; if (livingTargetInRange(selected, origin, acquisitionRange * 2, true)) return selected.id; const current = currentTargetId ? mobs[currentTargetId] : null; // currentTargetId was already authorized while it intersected the objective // corridor. Keep pursuing it while its mob-specific route is active. if (livingTargetInRange(current, origin, acquisitionRange * 1.5)) return current.id; return Object.values(mobs) .filter((mob) => livingTargetInRange(mob, origin, acquisitionRange, true)) .sort((left, right) => { // Clear adds before committing to the boss at the end of a pull. const kindOrder = Number(left.boss) - Number(right.boss); if (kindOrder) return kindOrder; const leftPosition = getMobPosition(left.id)!; const rightPosition = getMobPosition(right.id)!; return distanceSquared(leftPosition, origin) - distanceSquared(rightPosition, origin) || left.id.localeCompare(right.id); })[0]?.id ?? null; } export interface HealerSupportResult { readonly members: readonly PartyMember[]; readonly playerHealing: number; readonly playerResurrectionPercent: number; readonly targetId: string | null; readonly effectiveHealing: number; readonly sourceMemberId: string | null; readonly action: "heal" | "resurrection" | "dispel" | null; readonly presentation: PartyPresentationIdentity | null; readonly dispelCategories: readonly DispelCategory[]; readonly dispelMaximum: number; } function partyHealAmount(level: number, spellPower = 0): number { return Math.max(10, Math.round(12 + Math.max(1, level) * 2.6 + Math.max(0, spellPower) * 1.25)); } function partyResurrectionAbility(member: PartyMember): AbilityDefinition | null { return abilitiesForCharacter(member.classId, member.secondaryClassId) .filter((ability) => !ability.passive && (ability.target === "friendly" || ability.target === "any")) .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks)) .filter((ability): ability is AbilityDefinition => ability !== null) .find((ability) => ability.effects.some((effect) => effect.kind === "resurrection")) ?? null; } function partyResurrectionPresentation( member: PartyMember, ability: AbilityDefinition, ): PartyPresentationIdentity { const nature = member.classId === "druid" || member.classId === "shaman" || member.classId === "rom-druid"; return { spellId: ability.dbcSpellId, name: ability.name, school: nature ? "nature" : "holy", delivery: "projectile", }; } function noSupport(members: readonly PartyMember[]): HealerSupportResult { return { members, playerHealing: 0, playerResurrectionPercent: 0, targetId: null, effectiveHealing: 0, sourceMemberId: null, action: null, presentation: null, dispelCategories: [], dispelMaximum: 0, }; } /** * 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 = commitPartyAbilityUse(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 = commitPartyAbilityUse(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 = partyAbilityAmount(healer, healingAbility, healingEffect) + 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 = commitPartyAbilityUse(next[healerIndex], healingAbility, now) ?? next[healerIndex]; next[healerIndex] = { ...currentHealer, status: "healing", nextActionAt: now + Math.max(HEALER_ACTION_INTERVAL_MS, healingAbility.castTimeMs, healingAbility.gcdMs), }; return { members: next, playerHealing: target.index < 0 ? healed : 0, playerResurrectionPercent: 0, targetId: target.id, effectiveHealing: healed, sourceMemberId: healer.id, action: "heal", presentation: { spellId: healingAbility.dbcSpellId, name: healingAbility.name, school: presentationSchoolForAbility(healingAbility), delivery: presentationDeliveryForAbility(healingAbility), }, dispelCategories: [], dispelMaximum: 0, }; } function actorRole(actorId: string | null, members: readonly PartyMember[]): PartyMember["role"] | null { if (!actorId) return null; if (actorId === PLAYER_AGGRO_ID) return usePartyStore.getState().playerRole; return members.find((member) => member.id === actorId)?.role ?? null; } function tankPriorityTargetId( mobs: Readonly>, members: readonly PartyMember[], tankId: string, tankPosition: MobVector3, now: number, fallbackTargetId: string | null, ): string | null { const victimPriority = (mob: MobCombatState) => { const role = actorRole(mob.targetActorId, members); return role === "healer" ? 0 : role === "damage" ? 1 : role === "tank" ? 2 : 0; }; const loose = Object.values(mobs) .filter((mob) => ( mob.engaged && !mob.dead && mob.targetActorId !== tankId && !(mob.forcedTarget && mob.forcedTarget.endsAt > now) && getMobPosition(mob.id) )) .sort((left, right) => { const victimOrder = victimPriority(left) - victimPriority(right); if (victimOrder) return victimOrder; const bossOrder = Number(right.boss) - Number(left.boss); if (bossOrder) return bossOrder; return distanceSquared(getMobPosition(left.id)!, tankPosition) - distanceSquared(getMobPosition(right.id)!, tankPosition) || left.id.localeCompare(right.id); }); return loose[0]?.id ?? fallbackTargetId; } function memberSwingMs(member: PartyMember, now: number): number { const 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 = 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); return Math.round(hastedDuration( base, effectiveHaste, 500, ) / raceAffixHasteMultiplier()); } 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"], }, }; } /** Weapon swings continue during the GCD and supply Rage for paid attacks. */ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: number): PartyMember { if (partyUsesRangedAttacks(member) && baseClassFor(member.classId) !== "hunter") 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)) || 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 result = useCombatStore.getState().damageMob(targetId, partyAttackDamage(member), now, { actorId: member.id, role: member.role, abilityId }, "physical", scentRank > 0 ? { attackKind: "melee", seed: `${member.id}:${targetId}:${abilityId}:${now}:scent-of-blood-auto-attack`, abilityId, } : undefined); let runtimeMember = consumePartyScentOfBloodMeleeCharge( member, abilityId, scentRank > 0 && (result.combatResult?.landed ?? result.amount > 0), now, ); if (member.classId === "rom-rogue" && result.amount > 0) { 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 application = learnedPoison && poisonActive && !combat.mobs[targetId]?.dead ? romRoguePoisonApplicationForAttack({ classId: member.classId, sourceActorId: member.id, targetActorId: targetId, attackKind: partyUsesRangedAttacks(member) ? baseClassFor(member.classId) === "hunter" ? "ranged" : "spell" : "melee", isNormalAttack: true, attackLanded: true, poisonActive, roll: stablePartyUnit(`${member.id}:${targetId}:${abilityId}:${now}:rom-rogue-poison`), }) : null; if (application) { useCombatStore.setState((state) => ({ auras: [...applyCombatAura(state.auras, { definition: application.definition, sourceId: application.sourceId, targetId: application.targetId, stacks: application.stacks, now, }).auras], })); } } 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 (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 (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") { if (ability.dbcSpellId === 491613 && mob?.creatureFamily === "living" && !(member.classId === "rom-knight" && member.secondaryClassId === "rom-scout" && member.level >= 60)) return false; if (ability.dbcSpellId === 490347 && mob?.creatureFamily === "undead") return false; if (ability.dbcSpellId === 490151 && (member.comboTargetId !== targetId || member.comboPoints <= 0)) return false; if (ability.dbcSpellId === 490347 && !combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("rom-poisonous"))) 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 maximumRange = attackRange + flameThrowingBonusYards; 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 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]; } 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.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 executePartyAbility( member: PartyMember, ability: AbilityDefinition, targetId: string, now: number, members: readonly PartyMember[], depth = 0, castStartAlreadyEmitted = false, channelPulse = false, tidalWavesChargesConsumed = 0, arcanePotencyCriticalChanceBonusPercent = 0, manaSpent = 0, ): 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) : []; 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 = presentationSchoolForAbility(ability); 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, ratingPercent( member.gearStats.criticalStrikeRating, "spellCrit", member.level, member.classId, ) / 100 + elementalOathRank / 100 + (consumesInnerFocus ? 0.25 : 0) + wow335ShamanTidalWavesCriticalChanceBonusPercent(ability.name, tidalWavesChargesConsumed) / 100); const masterOfElementsCritPercent = member.classId === "mage" ? Math.min(3, Math.max(0, Math.trunc(member.talentRanks[WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID] ?? 0))) : 0; const frostbiteRank = member.classId === "mage" ? Math.min(3, Math.max(0, Math.trunc(member.talentRanks[WOW335_MAGE_FROSTBITE_TALENT_ID] ?? 0))) : 0; if (ability.name === "Inner Focus") pendingInnerFocus.add(member.id); const envenomDosesByTarget = new Map(); let suddenDoomTrigger: ReturnType = null; if (ability.name === "Envenom") { for (const hostileTargetId of hostileTargets) { const doses = Math.min( member.comboPoints, 5, matchingAuraStacks( combat.auras, ROGUE_DEADLY_POISON_DOSE_TAG, member.id, hostileTargetId, now, ), ); envenomDosesByTarget.set(hostileTargetId, doses); if (doses > 0 && !rogueEnvenomPreservesDeadlyPoison( member.talentRanks, `${member.id}:${hostileTargetId}:${ability.id}:${now}:master-poisoner`, )) { combat.consumeRogueDeadlyPoison(member.id, hostileTargetId, doses, now); } } } let runtimeMember = member; let effectiveAmount = 0; let directHealingRawAmount = 0; const empoweredFireRank = member.classId === "mage" ? member.talentRanks[WOW335_MAGE_EMPOWERED_FIRE_TALENT_ID] ?? 0 : 0; const 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" && 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"]?.weaponType === "dagger", casterMaxHealth: member.maxHealth, targetHealthPercent: combat.mobs[targetId] ? combat.mobs[targetId]!.health / Math.max(1, combat.mobs[targetId]!.maxHealth) : 1, naturePower: member.resourcePools["nature-power"] ?? 0, 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 (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) { 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 hostileTargetId of hostileTargets) { 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 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 = partyWarlockAbilityAmount( runtimeMember, ability, effect, envenomDosesByTarget.get(hostileTargetId) ?? 0, ); 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 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 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 partyUsesCombatRollContext = drumsOfWarShot || lightOfDayHit || deathKnightSuddenDoomTrigger || deathKnightScentOfBloodMeleeHit || consumesColdBlood || usesCombustion || arcanePotencyCriticalChanceBonusPercent > 0 || masterOfElementsCritPercent > 0 || (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 partyRawDirectDamage = mageTargetDamageAmount + sunderStacks * (effect.kind === "damage" ? effect.bonusPerSunderStack ?? 0 : 0); const partyDirectDamageWithMoltenFury = effect.kind === "damage" && partyMageMoltenFuryRank > 0 && !partyUsesCombatRollContext && partyDamageTargetBeforeHit ? wow335MageMoltenFuryDamage( partyRawDirectDamage, partyMageMoltenFuryRank, spellSchool, partyDamageTargetBeforeHit.health, partyDamageTargetBeforeHit.maxHealth, ) : partyRawDirectDamage; const result = combat.damageMob( hostileTargetId, partyDirectDamageWithMoltenFury, now, { actorId: member.id, role: member.role, abilityId: ability.id }, spellSchool, partyUsesCombatRollContext ? { attackKind: drumsOfWarShot ? "ranged" : deathKnightScentOfBloodMeleeHit || consumesColdBlood || deathKnightSuddenDoomTrigger ? "melee" : "spell", abilityId: ability.id, seed: `${member.id}:${ability.id}:${hostileTargetId}:${now}:special-crit`, criticalChanceBonusPercent: (consumesColdBlood ? 100 : 0) + arcanePotencyCriticalChanceBonusPercent + masterOfElementsCritPercent, ...(ability.name === "Shiv" ? { rules: { canCrit: false } } : {}), } : undefined, ); effectiveAmount += result.amount; const partyHitLanded = result.combatResult?.landed ?? result.amount > 0; 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 === "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, partyAbilityAmount(runtimeMember, ability, bonus), 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 === "heal") { const healingTargetIds = healingRecipientIds(effect, member.id, targetId, combat.actors, id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now); 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 ?? partyAbilityAmount(runtimeMember, swiftmendHotAbility, swiftmendHotEffect)) * 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 ?? partyAbilityAmount(runtimeMember, ability, effect)) + 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, abilityId: ability.id, school: healingSchool, criticalChance, seed: `${member.id}:${ability.id}:${friendlyTargetId}:${now}:heal`, now, }); 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); } } } else if (effect.kind === "hot") { if (!groundAreaCreated) for (const friendlyTargetId of healingRecipientIds(effect, member.id, targetId, combat.actors, id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now)) { 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, partyAbilityAmount(runtimeMember, ability, effect), 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, partyAbilityAmount(runtimeMember, ability, effect)), 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 interrupted = combat.interruptMob(hostileTargetId, ability.id, effect.lockoutMs, 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, ); } } 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); 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] : combat.mobs[targetId] ? hostileTargets : friendlyTargets; for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== member.id)) { const appliedEffect = 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; 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, effect.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") { for (const friendlyTargetId of friendlyTargets.length ? friendlyTargets : [member.id]) { const resource = effect.resource ?? member.resourceType; 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. if (friendlyTargetId === member.id) { const resourceMirror = memberFromCompanionActor( runtimeMember, useCombatStore.getState().actors[member.id], ); const restoredPool = resourceMirror.resourcePools[resource]; if (restoredPool === undefined) continue; runtimeMember = { ...runtimeMember, ...(resource === runtimeMember.resourceType ? { resource: restoredPool, maxResource: resourceMirror.maxResource } : {}), resourcePools: { ...runtimeMember.resourcePools, [resource]: restoredPool }, ...(resourceMirror.lastResourceSpentAt[resource] === undefined ? {} : { lastResourceSpentAt: { ...runtimeMember.lastResourceSpentAt, [resource]: resourceMirror.lastResourceSpentAt[resource], } }), }; } } } else if (effect.kind === "combo") { if (ability.dbcSpellId === 490151) { useCombatStore.setState((state) => ({ effects: state.effects.filter((entry) => !(entry.kind === "dot" && entry.tag === "rom-holy-seal" && entry.sourceActorId === member.id && entry.targetId === targetId)), })); } 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") { 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); } else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) { const summonEffect: Extract = { kind: "summon", creatureId: effect.creatureId ?? 1, coefficient: 0.35, durationMs: Number.MAX_SAFE_INTEGER - now, }; const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead ? targetId : combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead ? combat.selectedTargetId : Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null; useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now); } else if (effect.kind === "movement") { const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId); if (ability.dbcSpellId === 490344) { const target = getMobRuntimeTransform(targetId); if (target) updatePartyRuntimePosition(member.id, target.position[0] - Math.sin(target.yaw) * 1.5, target.position[1], target.position[2] - Math.cos(target.yaw) * 1.5); } else if (ability.dbcSpellId === 490150 && targetPosition) { const dx = targetPosition[0] - origin[0]; const dz = targetPosition[2] - origin[2]; const length = Math.max(0.001, Math.hypot(dx, dz)); updatePartyRuntimePosition(member.id, targetPosition[0] - dx / length * 1.5, targetPosition[1], targetPosition[2] - dz / length * 1.5); } else if (ability.dbcSpellId === 54785 && targetPosition) { const dx = targetPosition[0] - origin[0]; const dz = targetPosition[2] - origin[2]; const length = Math.max(0.001, Math.hypot(dx, dz)); updatePartyRuntimePosition(member.id, targetPosition[0] - dx / length * 1.5, targetPosition[1], targetPosition[2] - dz / length * 1.5); } else if (effect.toward === "away") { const danger = Object.values(combat.mobs) .filter((mob) => mob.engaged && !mob.dead) .sort((left, right) => ( Number(right.targetActorId === member.id) - Number(left.targetActorId === member.id) || Number(right.boss) - Number(left.boss) || left.id.localeCompare(right.id) ))[0]; const dangerPosition = danger ? getMobPosition(danger.id) : null; if (dangerPosition) { const dx = origin[0] - dangerPosition[0]; const dz = origin[2] - dangerPosition[2]; const length = Math.max(0.001, Math.hypot(dx, dz)); const distance = effect.movement === "teleport" ? 15 : 10; updatePartyRuntimePosition( member.id, origin[0] + dx / length * distance, origin[1], origin[2] + dz / length * distance, ); } } else if (targetPosition && effect.movement !== "knockback") { updatePartyRuntimePosition(member.id, targetPosition[0], targetPosition[1], targetPosition[2]); } } else if (effect.kind === "threat") { combat.applyActorThreatEffect( member.id, effect, effect.mode === "redirect" && !combat.mobs[targetId] ? targetId : null, now, ); } else if (effect.kind === "cooldown-reset") { const candidates = cooldownResetCandidates( Object.keys(runtimeMember.cooldowns) .map((id) => abilityById(id)) .filter((candidate): candidate is AbilityDefinition => candidate !== null), ability.id, effect.abilityName, ).map((candidate) => candidate.id); const cooldowns = { ...runtimeMember.cooldowns }; for (const id of candidates) { const readyAt = cooldowns[id] ?? now; const reduction = effect.amountPercent !== undefined ? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, effect.amountPercent)) : effect.amountMs ?? Number.MAX_SAFE_INTEGER; cooldowns[id] = Math.max(now, readyAt - reduction); } runtimeMember = { ...runtimeMember, cooldowns }; } else if (effect.kind === "resource-drain" && !combat.mobs[targetId]) { 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 resourcePools: Partial> = { ...runtimeMember.resourcePools, [resource]: Math.max(0, current - drained), }; 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 (!channelPulse && depth === 0 && member.classId === "rom-priest" && member.secondaryClassId === "rom-mage" && ability.source === "runewaker" && ability.dbcSpellId === ROM_PRIEST_MAGE_ENLIGHTENMENT.triggerSpellId) { const passiveSource = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.classId === "rom-priest" && candidate.secondaryClassId === "rom-mage" && candidate.passive && candidate.dbcSpellId === ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId )); const passive = passiveSource ? learnedAbilityForBuild(passiveSource, member.level, member.talentRanks) : null; if (passive) { const readyAt = romPriestMageEnlightenmentReadyAt( useCombatStore.getState().auras, member.id, now, ); const proc = romPriestMageEnlightenmentProc( member.classId, member.secondaryClassId, ability, [ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId], true, 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_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT, now, ); 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 (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(); members = members.map((member) => { const profile = resourceProfileForClass(member.classId); const passiveRates: Partial> = { mana: 4, rage: 0, energy: 10, focus: 6, "runic-power": 0, }; const resourcePools: Partial> = { ...member.resourcePools }; for (const type of Object.keys(resourcePools) as ResourceType[]) { const manaRegeneration = member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02; const regeneration = type === member.resourceType ? (profile.type === "rage" || profile.type === "runic-power" ? 0 : profile.regenerationPerSecond + manaRegeneration) : (passiveRates[type] ?? 0) + (type === "mana" ? manaRegeneration : 0); 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, resource: resourcePools[member.resourceType] ?? member.resource, resourcePools, warriorAngerManagementRageRemainderMs, cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)), schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)), }; }); const objectiveBossId = nextPartyBossObjective(combat.mobs)?.id ?? null; let memberTargetIds: Record = {}; const shouldRegen = !targetId && now >= nextRegenAt; if (shouldRegen) nextRegenAt = now + 1_000; if (!targetId) { members = members.map((member) => { if ( member.health <= 0 || ( member.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)) { nextMembers[index] = { ...member, 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) { nextMembers[index] = { ...member, 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; nextMembers[index] = { ...member, 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); nextMembers[index] = { ...member, 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(partyAbilityAmount(member, castingAbility, effect) * 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); member = execution.member; completed += 1; } member = { ...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, ); nextMembers[index] = { ...execution.member, 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 { nextMembers[index] = { ...completedMember, status: hostileCast ? "advancing" : "ready", nextActionAt: now + 250, }; } continue; } const supportIntent = choosePartySupportIntent(member, nextMembers, now); 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 consumesHunterLockAndLoad = partyHunterLockAndLoadReady(member, selectedCastAbility, now); const castAbility = partyAbilityWithHunterLockAndLoad(member, selectedCastAbility, now); const committed = commitPartyAbilityUse(member, castAbility, now, { ignoreResourceCosts: pendingInnerFocus.has(member.id) && abilityHasDirectHealing(castAbility), }); if (!committed) { nextMembers[index] = { ...member, status: "ready", nextActionAt: now + 250 }; continue; } 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); 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?.castTimeMs ?? Math.round(castAbility.castTimeMs / raceAffixHasteMultiplier(now)); 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 } : {}), ...(arcanePotencyBonus > 0 ? { arcanePotencyCriticalChanceBonusPercent: arcanePotencyBonus } : {}), ...(member.classId === "mage" ? { manaSpent } : {}), }, activeCastTargetId: supportIntent.targetId, nextActionAt: now + castTimeMs, }; emitPartyAbility(member, castAbility, supportIntent.targetId, "cast-start", now); } else { const execution = executePartyAbility( committedAfterHunterLockAndLoad, castAbility, supportIntent.targetId, now, nextMembers, 0, false, false, tidalWavesCast?.chargesConsumed ?? 0, arcanePotencyBonus, manaSpent, ); 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 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: currentCombat.combatEvents.some((event) => ( event.targetActorId === member.id && event.occurredAt > now - 5_000 && ["dodge", "parry", "block"].includes(event.outcome ?? "") )) ? new Set(["Revenge"]) : 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, })), 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.expiresAt > now) .map((summon) => summon.sourceAbilityId)), hasActivePet: currentCombat.summons.some((summon) => { if (summon.ownerActorId !== member.id || 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 backdraftCast = partyWarlockBackdraftCast(runtimeMember, selectedAbility, now); const tidalWavesCast = partyShamanTidalWavesCast(runtimeMember, selectedAbility, now); const selectedCastAbility = backdraftCast?.ability ?? tidalWavesCast?.ability ?? selectedAbility; const consumesHunterLockAndLoad = partyHunterLockAndLoadReady(runtimeMember, selectedCastAbility, now); const castAbility = partyAbilityWithHunterLockAndLoad(runtimeMember, selectedCastAbility, now); const committed = commitPartyAbilityUse(runtimeMember, castAbility, now); if (!committed) { nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + 250 }; continue; } 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); 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?.castTimeMs ?? Math.round(castAbility.castTimeMs / raceAffixHasteMultiplier(now)); 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 } : {}), ...(arcanePotencyBonus > 0 ? { arcanePotencyCriticalChanceBonusPercent: arcanePotencyBonus } : {}), ...(runtimeMember.classId === "mage" ? { manaSpent } : {}), }, activeCastTargetId: abilityTargetId, nextActionAt: now + castTimeMs, }; emitPartyAbility(member, castAbility, abilityTargetId, "cast-start", now); } else { const execution = executePartyAbility( committedAfterHunterLockAndLoad, castAbility, abilityTargetId, now, nextMembers, 0, false, false, tidalWavesCast?.chargesConsumed ?? 0, arcanePotencyBonus, manaSpent, ); nextMembers[index] = { ...execution.member, status: desiredStatus, nextActionAt: now + Math.max(250, backdraftCast?.gcdMs ?? Math.round(castAbility.gcdMs / raceAffixHasteMultiplier(now))), }; } } 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); return actor?.kind === "companion" && baseline && actor.health !== baseline.health ? { ...member, health: actor.health, maxHealth: actor.maxHealth } : member; }); 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); }