Files
healer-man/src/game/partyRuntime.ts
T
2026-09-30 13:25:55 -04:00

3446 lines
152 KiB
TypeScript

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<typeof pool[number] | undefined>((shortest, candidate) => {
if (!shortest) return candidate;
const expiry = candidate.nextTickAt + Math.max(0, candidate.remainingTicks - 1) * candidate.intervalMs;
const shortestExpiry = shortest.nextTickAt + Math.max(0, shortest.remainingTicks - 1) * shortest.intervalMs;
return expiry < shortestExpiry ? candidate : shortest;
}, undefined);
}
function maybeProcPartyShamanImprovedWaterShield(
member: PartyMember,
ability: AbilityDefinition,
targetId: string,
school: CombatPresentationSchool,
critical: boolean,
now: number,
): PartyMember {
if (member.classId !== "shaman" || ability.source === "runewaker"
|| !wow335ShamanImprovedWaterShieldShouldProc(
member.talentRanks["talent-583"] ?? 0,
ability.name,
school,
critical,
stablePartyUnit(`${member.id}:${ability.id}:${targetId}:${now}:wow335-shaman-improved-water-shield`),
)) return member;
const requested = wow335ShamanWaterShieldOrbManaAtLevel(member.level);
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<string, number>();
const pendingInnerFocus = new Set<string>();
export interface PartyPresentationIdentity {
readonly spellId: number;
readonly name: string;
readonly school: CombatPresentationSchool;
readonly delivery: CombatPresentationDelivery;
}
function partyAttackPresentation(member: PartyMember): PartyPresentationIdentity {
if (!partyUsesRangedAttacks(member)) {
const spellId = member.classId === "rogue" ? 1752
: member.classId === "death-knight" ? 45462
: member.classId === "paladin" ? 20271
: 78;
return { spellId, name: "Weapon Attack", school: "physical", delivery: "melee" };
}
if (member.classId === "hunter") return { spellId: 56641, name: "Steady Shot", school: "physical", delivery: "projectile" };
if (member.classId === "mage") return { spellId: 116, name: "Frostbolt", school: "frost", delivery: "projectile" };
if (member.classId === "warlock") return { spellId: 686, name: "Shadow Bolt", school: "shadow", delivery: "projectile" };
if (member.classId === "priest") return { spellId: 585, name: "Smite", school: "holy", delivery: "projectile" };
if (member.classId === "druid") return { spellId: 5176, name: "Wrath", school: "nature", delivery: "projectile" };
return { spellId: 403, name: "Lightning Bolt", school: "nature", delivery: "projectile" };
}
function partyHealPresentation(member: PartyMember): PartyPresentationIdentity {
if (member.classId === "paladin") return { spellId: 635, name: "Holy Light", school: "holy", delivery: "projectile" };
if (member.classId === "shaman") return { spellId: 331, name: "Healing Wave", school: "nature", delivery: "projectile" };
if (member.classId === "druid") return { spellId: 5185, name: "Healing Touch", school: "nature", delivery: "projectile" };
return { spellId: 2061, name: "Flash Heal", school: "holy", delivery: "projectile" };
}
function partyTauntSpellId(member: PartyMember): number {
if (member.classId === "paladin") return 62124;
if (member.classId === "death-knight") return 56222;
if (member.classId === "druid") return 6795;
return 355;
}
export interface PartyBossObjective {
readonly id: string;
readonly name: string;
readonly position: MobVector3;
}
export function activePartyBossObjectives(): readonly PartyBossObjective[] {
if (useGameStore.getState().gameMode === "manastorm") {
const encounter = useManastormStore.getState().currentEncounter;
return encounter ? [{
id: encounter.bossRuntimeId,
name: encounter.bossName,
position: encounter.bossPosition,
}] : [];
}
const game = useGameStore.getState();
const simulation = useDungeonSimulationStore.getState();
if (
simulation.sessionMode === "spectate-live"
&& simulation.mode === "navigation-only"
&& simulation.objectiveId
) {
const objective = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
.find((candidate) => candidate.id === simulation.objectiveId);
return objective ? [objective] : [];
}
const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId));
const mobs = useCombatStore.getState().mobs;
const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
.filter((objective) => !objective.optional && !completed.has(objective.id));
const wailingPhase = game.activeDungeonId === "wailing-caverns"
? useWailingEncounterStore.getState().phase
: null;
return objectives.filter((objective) => (
!objective.eventOnly
|| Boolean(mobs[objective.id])
|| (objective.id === "mutanus-spawn"
&& (wailingPhase === "mutanus" || wailingPhase === "complete"))
));
}
function simulatedPartyExcludesPlayer(): boolean {
return useDungeonSimulationStore.getState().sessionMode !== "play";
}
export function resetPartyRuntimeClock(): void {
lastAdvanceAt = 0;
nextRegenAt = 0;
nextWeaponAttackAt.clear();
pendingInnerFocus.clear();
}
export function activePartyTargetId(
mobs: Readonly<Record<string, MobCombatState>>,
selectedTargetId: string | null,
): string | null {
const selected = selectedTargetId ? mobs[selectedTargetId] : null;
if (selected?.engaged && !selected.dead) return selected.id;
return Object.values(mobs)
.filter((mob) => mob.engaged && !mob.dead)
.sort((left, right) => Number(right.boss) - Number(left.boss) || left.id.localeCompare(right.id))[0]?.id ?? null;
}
export function partyAttackDamage(
member: Pick<PartyMember, "level" | "role">
& Partial<Pick<PartyMember, "classId" | "specialization" | "gearStats">>,
): 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<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
): boolean {
if (member.role === "tank") return false;
if (member.role === "healer") return true;
const definition = classById(member.classId);
if (definition.mode !== "classic") {
return (baseClassFor(member.classId) === "hunter"
|| ["intellect", "wisdom", "spirit"].includes(definition.primaryStat))
&& partyCatalogAttackRange(member) > 5;
}
return ["hunter", "mage", "priest", "warlock"].includes(member.classId)
|| member.specialization === "Elemental"
|| member.specialization === "Balance";
}
function partyCatalogAttackRange(member: Pick<PartyMember, "classId"> & Partial<Pick<PartyMember, "level" | "talentRanks">>): number {
return Math.max(0, ...abilitiesForClass(member.classId)
.filter((ability) => !ability.passive)
.map((ability) => learnedAbilityForBuild(ability, member.level ?? 80, member.talentRanks ?? {}))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind)))
.map((ability) => ability.range.max));
}
export function partyAttackRange(member: Pick<PartyMember, "classId" | "specialization" | "role">): number {
if (member.role === "tank") return 3.8;
if (partyUsesRangedAttacks(member)) return 19;
return 4.2;
}
export interface PartyCombatRangeBand {
readonly minimum: number;
readonly preferred: number;
readonly maximum: number;
}
const RANGED_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({
minimum: 10,
preferred: 14.5,
maximum: 18.1,
});
const TANK_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({
minimum: 0,
preferred: 2.9,
maximum: 3.15,
});
const MELEE_DAMAGE_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({
minimum: 0,
preferred: 3.3,
maximum: 3.55,
});
/**
* Movement uses a narrower band than damage validation. Ranged attackers keep
* enough room to read as ranged while melee attackers stay close to a fleeing
* target instead of treating the tank's position as their own.
*/
export function partyCombatRangeBand(
member: Pick<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
): PartyCombatRangeBand {
if (partyUsesRangedAttacks(member)) {
if (classById(member.classId).mode !== "classic") {
const attackRange = partyCatalogAttackRange(member);
if (attackRange <= 5) return RANGED_COMBAT_RANGE_BAND;
const maximum = Math.min(RANGED_COMBAT_RANGE_BAND.maximum, attackRange - 0.1);
const preferred = Math.min(RANGED_COMBAT_RANGE_BAND.preferred, maximum - 0.5);
return { minimum: Math.min(RANGED_COMBAT_RANGE_BAND.minimum, preferred - 0.5), preferred, maximum };
}
return RANGED_COMBAT_RANGE_BAND;
}
return member.role === "tank" ? TANK_COMBAT_RANGE_BAND : MELEE_DAMAGE_COMBAT_RANGE_BAND;
}
export function nextPartyBossObjective(
mobs: Readonly<Record<string, MobCombatState>>,
): PartyBossObjective | null {
return activePartyBossObjectives().find((objective) => !mobs[objective.id]?.dead) ?? null;
}
function distanceSquared(left: MobVector3, right: MobVector3): number {
const dx = left[0] - right[0];
const dy = left[1] - right[1];
const dz = left[2] - right[2];
return dx * dx + dy * dy + dz * dz;
}
function livingTargetInRange(
mob: MobCombatState | null | undefined,
origin: MobVector3,
maximumRange: number,
requireAttackCorridor = false,
): mob is MobCombatState {
if (!mob || mob.dead) return false;
const position = getMobPosition(mob.id);
return Boolean(
position
&& distanceSquared(position, origin) <= maximumRange * maximumRange
&& (!requireAttackCorridor || partyRuntimePositionAlongAttackCorridor(position)),
);
}
export function partyCommandTargetId(
command: PartyCommand,
mobs: Readonly<Record<string, MobCombatState>>,
selectedTargetId: string | null,
currentTargetId: string | null,
origin: MobVector3,
acquisitionRange = ATTACK_ACQUISITION_RANGE,
): string | null {
if (command === "stop") return null;
const engaged = activePartyTargetId(mobs, selectedTargetId);
if (engaged) return engaged;
if (command === "defend") return null;
const selected = selectedTargetId ? mobs[selectedTargetId] : null;
if (livingTargetInRange(selected, origin, acquisitionRange * 2, true)) return selected.id;
const current = currentTargetId ? mobs[currentTargetId] : null;
// currentTargetId was already authorized while it intersected the objective
// corridor. Keep pursuing it while its mob-specific route is active.
if (livingTargetInRange(current, origin, acquisitionRange * 1.5)) return current.id;
return Object.values(mobs)
.filter((mob) => livingTargetInRange(mob, origin, acquisitionRange, true))
.sort((left, right) => {
// Clear adds before committing to the boss at the end of a pull.
const kindOrder = Number(left.boss) - Number(right.boss);
if (kindOrder) return kindOrder;
const leftPosition = getMobPosition(left.id)!;
const rightPosition = getMobPosition(right.id)!;
return distanceSquared(leftPosition, origin) - distanceSquared(rightPosition, origin)
|| left.id.localeCompare(right.id);
})[0]?.id ?? null;
}
export interface HealerSupportResult {
readonly members: readonly PartyMember[];
readonly playerHealing: number;
readonly playerResurrectionPercent: number;
readonly targetId: string | null;
readonly effectiveHealing: number;
readonly sourceMemberId: string | null;
readonly action: "heal" | "resurrection" | "dispel" | null;
readonly presentation: PartyPresentationIdentity | null;
readonly dispelCategories: readonly DispelCategory[];
readonly dispelMaximum: number;
}
function partyHealAmount(level: number, spellPower = 0): number {
return Math.max(10, Math.round(12 + Math.max(1, level) * 2.6 + Math.max(0, spellPower) * 1.25));
}
function partyResurrectionAbility(member: PartyMember): AbilityDefinition | null {
return abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((ability) => !ability.passive && (ability.target === "friendly" || ability.target === "any"))
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null)
.find((ability) => ability.effects.some((effect) => effect.kind === "resurrection")) ?? null;
}
function partyResurrectionPresentation(
member: PartyMember,
ability: AbilityDefinition,
): PartyPresentationIdentity {
const nature = member.classId === "druid"
|| member.classId === "shaman"
|| member.classId === "rom-druid";
return {
spellId: ability.dbcSpellId,
name: ability.name,
school: nature ? "nature" : "holy",
delivery: "projectile",
};
}
function noSupport(members: readonly PartyMember[]): HealerSupportResult {
return {
members,
playerHealing: 0,
playerResurrectionPercent: 0,
targetId: null,
effectiveHealing: 0,
sourceMemberId: null,
action: null,
presentation: null,
dispelCategories: [],
dispelMaximum: 0,
};
}
/**
* 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<Record<string, MobCombatState>>,
members: readonly PartyMember[],
tankId: string,
tankPosition: MobVector3,
now: number,
fallbackTargetId: string | null,
): string | null {
const victimPriority = (mob: MobCombatState) => {
const role = actorRole(mob.targetActorId, members);
return role === "healer" ? 0 : role === "damage" ? 1 : role === "tank" ? 2 : 0;
};
const loose = Object.values(mobs)
.filter((mob) => (
mob.engaged
&& !mob.dead
&& mob.targetActorId !== tankId
&& !(mob.forcedTarget && mob.forcedTarget.endsAt > now)
&& getMobPosition(mob.id)
))
.sort((left, right) => {
const victimOrder = victimPriority(left) - victimPriority(right);
if (victimOrder) return victimOrder;
const bossOrder = Number(right.boss) - Number(left.boss);
if (bossOrder) return bossOrder;
return distanceSquared(getMobPosition(left.id)!, tankPosition)
- distanceSquared(getMobPosition(right.id)!, tankPosition)
|| left.id.localeCompare(right.id);
});
return loose[0]?.id ?? fallbackTargetId;
}
function memberSwingMs(member: PartyMember, now: number): number {
const 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<string, number>();
let suddenDoomTrigger: ReturnType<typeof wow335DeathKnightSuddenDoomTriggerForLandedHit> = null;
if (ability.name === "Envenom") {
for (const hostileTargetId of hostileTargets) {
const doses = Math.min(
member.comboPoints,
5,
matchingAuraStacks(
combat.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
member.id,
hostileTargetId,
now,
),
);
envenomDosesByTarget.set(hostileTargetId, doses);
if (doses > 0 && !rogueEnvenomPreservesDeadlyPoison(
member.talentRanks,
`${member.id}:${hostileTargetId}:${ability.id}:${now}:master-poisoner`,
)) {
combat.consumeRogueDeadlyPoison(member.id, hostileTargetId, doses, now);
}
}
}
let runtimeMember = member;
let effectiveAmount = 0;
let directHealingRawAmount = 0;
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<AbilityEffect, { kind: "summon" }> = {
kind: "summon",
creatureId: effect.creatureId ?? 1,
coefficient: 0.35,
durationMs: Number.MAX_SAFE_INTEGER - now,
};
const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead
? targetId
: combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead
? combat.selectedTargetId
: Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now);
} else if (effect.kind === "movement") {
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
if (ability.dbcSpellId === 490344) {
const target = getMobRuntimeTransform(targetId);
if (target) updatePartyRuntimePosition(member.id,
target.position[0] - Math.sin(target.yaw) * 1.5,
target.position[1],
target.position[2] - Math.cos(target.yaw) * 1.5);
} else if (ability.dbcSpellId === 490150 && targetPosition) {
const dx = targetPosition[0] - origin[0];
const dz = targetPosition[2] - origin[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
updatePartyRuntimePosition(member.id,
targetPosition[0] - dx / length * 1.5,
targetPosition[1],
targetPosition[2] - dz / length * 1.5);
} else if (ability.dbcSpellId === 54785 && targetPosition) {
const dx = targetPosition[0] - origin[0];
const dz = targetPosition[2] - origin[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
updatePartyRuntimePosition(member.id,
targetPosition[0] - dx / length * 1.5,
targetPosition[1],
targetPosition[2] - dz / length * 1.5);
} else if (effect.toward === "away") {
const danger = Object.values(combat.mobs)
.filter((mob) => mob.engaged && !mob.dead)
.sort((left, right) => (
Number(right.targetActorId === member.id) - Number(left.targetActorId === member.id)
|| Number(right.boss) - Number(left.boss)
|| left.id.localeCompare(right.id)
))[0];
const dangerPosition = danger ? getMobPosition(danger.id) : null;
if (dangerPosition) {
const dx = origin[0] - dangerPosition[0];
const dz = origin[2] - dangerPosition[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
const distance = effect.movement === "teleport" ? 15 : 10;
updatePartyRuntimePosition(
member.id,
origin[0] + dx / length * distance,
origin[1],
origin[2] + dz / length * distance,
);
}
} else if (targetPosition && effect.movement !== "knockback") {
updatePartyRuntimePosition(member.id, targetPosition[0], targetPosition[1], targetPosition[2]);
}
} else if (effect.kind === "threat") {
combat.applyActorThreatEffect(
member.id,
effect,
effect.mode === "redirect" && !combat.mobs[targetId] ? targetId : null,
now,
);
} else if (effect.kind === "cooldown-reset") {
const candidates = cooldownResetCandidates(
Object.keys(runtimeMember.cooldowns)
.map((id) => abilityById(id))
.filter((candidate): candidate is AbilityDefinition => candidate !== null),
ability.id,
effect.abilityName,
).map((candidate) => candidate.id);
const cooldowns = { ...runtimeMember.cooldowns };
for (const id of candidates) {
const readyAt = cooldowns[id] ?? now;
const reduction = effect.amountPercent !== undefined
? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, effect.amountPercent))
: effect.amountMs ?? Number.MAX_SAFE_INTEGER;
cooldowns[id] = Math.max(now, readyAt - reduction);
}
runtimeMember = { ...runtimeMember, cooldowns };
} else if (effect.kind === "resource-drain" && !combat.mobs[targetId]) {
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<Record<ResourceType, number>> = {
...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<Record<ResourceType, number>> = {
mana: 4,
rage: 0,
energy: 10,
focus: 6,
"runic-power": 0,
};
const resourcePools: Partial<Record<ResourceType, number>> = { ...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<string, string> = {};
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);
}