import type { RomNativeDerivedStats } from "./romNativeStatsTypes"; import type { RomNativeDamageTargetContext } from "./romNativeCombatAmount"; import type { EnemyDisarmWeaponChannel } from "./enemyWeaponDebuffs"; import { abilitiesForCharacter, learnedAbilityForBuild, type AbilityDefinition, type AbilityEffect, type ResourceType, } from "./abilityCatalog"; import { aiHintsForAbility, runeCostForAbility } from "./abilityRules"; import { evaluateCombatRotation, rotationProfileFor, type RotationDecision, type RotationFriendlyTarget, } from "./combatRotation"; import { spendDeathKnightRunes } from "./combatResources"; import { resolveResourceValue } from "./combatAuras"; import { combatHasLineOfSight } from "./combatSpatial"; import { equipmentAdjustedCombatAmount, useCombatStore, type MobCombatState } from "./combatStore"; import { getMobPosition } from "./mobRuntimeRegistry"; import type { PartyMember } from "./partyStore"; import { baseResourcesFor, deriveCharacterStats } from "./wotlkStats"; import { companionResourceMaximum } from "./combatActorAuthority"; import { currentCompanionCooldownPolicy, type CompanionCooldownMechanicRule } from "./companionCooldownPolicy"; import { WOW335_MAGE_ARCANE_CONCENTRATION_TAG } from "./wow335MageTalentRuntime"; import { WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID, wow335MageImprovedCounterspellCooldownReductionMs, } from "./wow335MageImprovedCounterspellRuntime"; import { WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID, wow335MageImprovedFireBlastCooldownReductionMs, wow335MageImprovedFireBlastDamage, } from "./wow335MageImprovedFireBlastRuntime"; import { WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG } from "./wow335HunterRuntime"; import { romPriestWarriorStanceRequired, romPriestWarriorStanceUseAllowed, } from "./romPriestKnightOverrides"; import { romKnightCompassionWhirlwindShieldCooldownReductionMs, romKnightWarriorWhirlwindAdjustment, } from "./romKnightOverrides"; import { romWarriorDruidWhirlwindAdjustment } from "./romWarriorDruidRageOfCalmness"; import { ROM_ROGUE_COERCE_PASSIVE_SPELL_ID, romRogueCoerceAbilityAdjustment, } from "./romRogueMechanics"; import { romPriestChampionCooldownOverride } from "./romPriestChampionOverrides"; import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers"; import { romWarlockMageSoulBrandStingHasWillpowerBladeState } from "./romWarlockMageSoulBrandSting"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { romBloodArrowCastCosts } from "./romBloodArrow"; import { coaWitchDoctorBrewingAbilityForCast, coaWitchDoctorIngredientIdForAbility } from "./coaWitchDoctorBrewingRuntime"; import { coaBarbarianDecapitateWeaponStrike, coaNormalizedMeleeWeaponSpeed } from "./coaBarbarianWeaponDamageRuntime"; import { coaGuardianFormationAbilityForCast } from "./coaGuardianFormationRuntime"; import { projectCoaGuardianParagonAbility } from "./coaGuardianParagonRuntime"; import { resolvedEquipmentLoadout } from "./heirloomEquipment"; export interface PartyAbilityContext { readonly now: number; readonly target: MobCombatState | null; readonly targetIsLoose?: boolean; readonly hasDangerousThreat?: boolean; readonly enemyUsesMeleeAttacks?: boolean; readonly incomingMeleeDanger?: boolean; readonly needsEscape?: boolean; readonly moving?: boolean; readonly requiredControl?: boolean; readonly allyNeedsDispel?: boolean; readonly fallenAlly?: boolean; readonly injuredAllies?: number; readonly lowestHealthFraction?: number; readonly hostileCount?: number; readonly friendlyTargets?: readonly RotationFriendlyTarget[]; readonly maintainedAbilityIds?: ReadonlySet; readonly activeAuraExclusiveGroups?: ReadonlySet; readonly activeSummonAbilityIds?: ReadonlySet; readonly hasActivePet?: boolean; readonly partyResourceDeficit?: (ability: AbilityDefinition) => boolean; readonly availableConditionalAbilityNames?: ReadonlySet; readonly canUseAbility?: (ability: AbilityDefinition) => boolean; readonly incomingDefensiveMechanic?: CompanionCooldownMechanicRule | null; /** Inner Focus-style setup lets the next direct healing spell ignore resource costs. */ readonly nextHealingSpellFree?: boolean; } export interface PartyResourcePoolSnapshot { readonly resources: Readonly>>; } export function partyAbilityHasDepletedPartyResource( ability: AbilityDefinition, recipients: readonly PartyResourcePoolSnapshot[], ): boolean { return ability.effects.some((effect) => { if (effect.kind !== "resource" || effect.scope !== "party" || !effect.resource) return false; return recipients.some(({ resources }) => { const pool = resources[effect.resource!]; return Boolean(pool && pool.current < pool.maximum); }); }); } function currentResource(member: PartyMember, type: ResourceType): number { return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0); } function partyRomPriestChampionCooldownOverride( member: PartyMember, ability: AbilityDefinition, ): number | null { if (member.classId !== "rom-priest" || member.secondaryClassId !== "rom-champion") return null; const infusedLightChain = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.classId === "rom-priest" && candidate.secondaryClassId === "rom-champion" && candidate.passive && candidate.dbcSpellId === 498610 )); if (!infusedLightChain || !learnedAbilityForBuild(infusedLightChain, member.level, member.talentRanks)) return null; return romPriestChampionCooldownOverride(ability, [infusedLightChain.dbcSpellId]); } function partyAbilityWithRomPriestChampionCooldown( member: PartyMember, ability: AbilityDefinition, ): AbilityDefinition { const cooldownOverride = partyRomPriestChampionCooldownOverride(member, ability); return cooldownOverride === null ? ability : { ...ability, cooldownMs: cooldownOverride }; } export function partyHunterLockAndLoadReady( member: PartyMember, ability: AbilityDefinition, now?: number, ): boolean { if (member.classId !== "hunter" || !["Arcane Shot", "Explosive Shot"].includes(ability.name) || Math.max(0, Math.trunc(member.talentRanks["talent-1306"] ?? 0)) <= 0) return false; return useCombatStore.getState().auras.some((aura) => aura.targetId === member.id && aura.stacks > 0 && aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) && (now === undefined || aura.expiresAt === null || aura.expiresAt > now)); } export function partyAbilityWithHunterLockAndLoad( member: PartyMember, ability: AbilityDefinition, now: number, ): AbilityDefinition { if (!partyHunterLockAndLoadReady(member, ability, now)) return ability; const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ( cost.resource === "mana" ? { ...cost, amount: 0 } : cost )); return { ...ability, cooldownMs: 0, cost: costs[0] ?? ability.cost, costs }; } function partyKnightWarriorWhirlwindAdjustment( member: PartyMember, ability: AbilityDefinition, ): ReturnType { if (member.classId !== "rom-knight" || member.secondaryClassId !== "rom-warrior") { return { rageCostReduction: 0, cooldownReductionMs: 0 }; } const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId) .filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-knight" && candidate.secondaryClassId === "rom-warrior" && candidate.dbcSpellId === 491639 && candidate.passive && learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null) .map((candidate) => candidate.dbcSpellId); return romKnightWarriorWhirlwindAdjustment( member.classId, member.secondaryClassId, ability, learnedPassiveSpellIds, ); } function partyWarriorDruidWhirlwindAdjustment( member: PartyMember, ability: AbilityDefinition, ): ReturnType { if (member.classId !== "rom-warrior" || member.secondaryClassId !== "rom-druid") { return { rageCostReduction: 0, cooldownReductionMs: 0 }; } const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId) .filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-warrior" && candidate.secondaryClassId === "rom-druid" && candidate.dbcSpellId === 494377 && candidate.passive && learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null) .map((candidate) => candidate.dbcSpellId); return romWarriorDruidWhirlwindAdjustment({ classId: member.classId, secondaryClassId: member.secondaryClassId, learnedPassiveSpellIds, ability, }); } function partyKnightCompassionCooldownReductionMs( member: PartyMember, ability: AbilityDefinition, ): number { if (member.classId !== "rom-knight" || member.secondaryClassId !== "rom-priest") return 0; const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId) .filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-knight" && candidate.secondaryClassId === "rom-priest" && candidate.dbcSpellId === 491635 && candidate.passive && learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null) .map((candidate) => candidate.dbcSpellId); return romKnightCompassionWhirlwindShieldCooldownReductionMs( member.classId, member.secondaryClassId, ability, learnedPassiveSpellIds, ); } function partyAbilityWithKnightWarriorWhirlwind( member: PartyMember, ability: AbilityDefinition, ): AbilityDefinition { const knightAdjustment = partyKnightWarriorWhirlwindAdjustment(member, ability); const warriorDruidAdjustment = partyWarriorDruidWhirlwindAdjustment(member, ability); const rageCostReduction = knightAdjustment.rageCostReduction + warriorDruidAdjustment.rageCostReduction; const cooldownReductionMs = knightAdjustment.cooldownReductionMs + warriorDruidAdjustment.cooldownReductionMs; if (rageCostReduction <= 0 && cooldownReductionMs <= 0) return ability; const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ( cost.resource === "rage" && !cost.percentage ? { ...cost, amount: Math.max(0, cost.amount - rageCostReduction) } : cost )); return { ...ability, cooldownMs: Math.max(0, ability.cooldownMs - cooldownReductionMs), cost: costs[0] ?? ability.cost, costs, }; } function partyRogueCoerceEnergyCostReduction(member: PartyMember, ability: AbilityDefinition): number { if (member.classId !== "rom-rogue" || member.secondaryClassId !== "rom-scout") return 0; const passiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId) .filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-rogue" && candidate.secondaryClassId === "rom-scout" && candidate.passive && candidate.dbcSpellId === ROM_ROGUE_COERCE_PASSIVE_SPELL_ID && learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null) .map((candidate) => candidate.dbcSpellId); return romRogueCoerceAbilityAdjustment( member.classId, member.secondaryClassId, ability.source, ability.dbcSpellId, passiveSpellIds, ).energyCostReduction; } function partyAbilityWithRogueCoerce(member: PartyMember, ability: AbilityDefinition): AbilityDefinition { const reduction = partyRogueCoerceEnergyCostReduction(member, ability); if (reduction <= 0) return ability; const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ( cost.resource === "energy" && !cost.percentage ? { ...cost, amount: Math.max(0, cost.amount - reduction) } : cost )); return { ...ability, cost: costs[0] ?? ability.cost, costs }; } export function partyAbilityCosts(member: PartyMember, ability: AbilityDefinition, now?: number): readonly { resource: ResourceType; amount: number }[] { const adjustedAbility = partyAbilityWithRogueCoerce( member, partyAbilityWithKnightWarriorWhirlwind(member, ability), ); const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id && (now === undefined || aura.expiresAt === null || aura.expiresAt > now)); const mageClearcasting = member.classId === "mage" && auras.some((aura) => ( aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG) )); const resolved = (adjustedAbility.costs?.length ? adjustedAbility.costs : [adjustedAbility.cost]).map((cost) => { const baseMana = cost.percentageBasis === "base-mana" ? adjustedAbility.source === "runewaker" && member.statRuleset === "rom" ? deriveCharacterStats( member.classId, member.level, EMPTY_ITEM_STATS, member.raceId, {}, member.secondaryClassId, member.gender, "rom", ).mana : baseResourcesFor(member.classId, member.level).mana : 0; const maximum = cost.percentageBasis === "base-mana" ? baseMana : cost.resource === "health" ? member.maxHealth : companionResourceMaximum(member, cost.resource); const baseAmount = cost.percentage ? cost.amount / 100 * maximum : cost.amount; const adjusted = resolveResourceValue( resolveResourceValue(baseAmount, auras, cost.resource, "cost"), auras, "power", "cost", ); return { resource: cost.resource, amount: cost.resource === "mana" && partyHunterLockAndLoadReady(member, ability, now) ? 0 : cost.resource !== "health" && mageClearcasting ? 0 : Math.max(0, Math.round(adjusted)), }; }); const bloodArrowCosts = romBloodArrowCastCosts( ability, member, auras, member.id, member.maxHealth, companionResourceMaximum(member, "mana"), now, ); return [...resolved, ...bloodArrowCosts]; } export function partyAbilityIsReady( member: PartyMember, ability: AbilityDefinition, now: number, ignoreResourceCosts = false, ): boolean { if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false; if (member.classId === "rom-warlock" && member.secondaryClassId === "rom-mage" && ability.source === "runewaker" && ability.dbcSpellId === 498710 && !romWarlockMageSoulBrandStingHasWillpowerBladeState(useCombatStore.getState().auras.filter((aura) => ( aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) )))) return false; if (romPriestWarriorStanceRequired(ability)) { const activeAuraTags = useCombatStore.getState().auras .filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)) .flatMap((aura) => aura.definition.utilityTags ?? []); if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false; } const hasRomPriestChampionCooldownOverride = partyRomPriestChampionCooldownOverride(member, ability) !== null; if ((ability.gcdMs > 0 && member.globalCooldownEndsAt > now) || (!partyHunterLockAndLoadReady(member, ability, now) && !hasRomPriestChampionCooldownOverride && (member.cooldowns[ability.id] ?? 0) > now)) return false; if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false; if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false; if (!ignoreResourceCosts && partyAbilityCosts(member, ability, now).some(cost => cost.resource === "health" ? member.health <= cost.amount : currentResource(member, cost.resource) < cost.amount)) return false; const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null; return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null; } function learnedAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] { const combat = useCombatStore.getState(); const mainHand = member.classId === "barbarian" ? resolvedEquipmentLoadout(combat.equipment, combat.inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, secondaryClassId: member.secondaryClassId, specializationId: member.specializationId, role: member.role, level: member.level, })["main-hand"] : null; return abilitiesForCharacter(member.classId, member.secondaryClassId) .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks)) .filter((ability): ability is AbilityDefinition => ability !== null) .map((ability) => partyAbilityWithRomPriestChampionCooldown(member, ability)) .map((ability) => coaWitchDoctorBrewingAbilityForCast(ability, combat.auras, member.id, now)) .map(coaGuardianFormationAbilityForCast) .map((ability) => projectCoaGuardianParagonAbility(ability, member, combat.auras, now)) .map((ability) => coaBarbarianDecapitateWeaponStrike(ability, member.gearStats.meleeWeapon, mainHand ? coaNormalizedMeleeWeaponSpeed(mainHand) : null)); } function isComboFinisher(ability: AbilityDefinition): boolean { return ability.effects.some((effect) => effect.kind === "finisher-damage" || (effect.kind === "combo" && effect.amount < 0)); } const RESERVED_SIGNATURE_ACTIONS = new Set(["Mortal Strike", "Mutilate", "Envenom"]); function shouldReserveForHigherPriority( member: PartyMember, learned: readonly AbilityDefinition[], decision: RotationDecision, now: number, ): boolean { const rotation = rotationProfileFor(member.classId, member.specializationId, member.specialization); const decisionIndex = rotation?.abilityPriority.indexOf(decision.ability.name) ?? -1; if (!rotation || decisionIndex <= 0) return false; return rotation.abilityPriority.slice(0, decisionIndex).some((name) => { const candidate = learned.find((ability) => ability.name === name); if (!candidate || candidate.passive) return false; if (!RESERVED_SIGNATURE_ACTIONS.has(candidate.name)) return false; if (isComboFinisher(candidate) && (member.comboPoints < 4 || member.comboTargetId !== decision.targetId)) return false; if ((member.cooldowns[candidate.id] ?? 0) > now) return false; if ((member.schoolLockedUntil[candidate.castRules?.school ?? "physical"] ?? 0) > now) return false; const categories = aiHintsForAbility(candidate).categories; if (!categories.includes("damage") || categories.includes("execute")) return false; const blockedCosts = partyAbilityCosts(member, candidate).filter((cost) => ( cost.amount > currentResource(member, cost.resource) )); if (!blockedCosts.length) return false; return blockedCosts.some((cost) => !decision.ability.effects.some((effect) => ( effect.kind === "resource" && (effect.resource ?? member.resourceType) === cost.resource && effect.amount > 0 ))); }); } export function choosePartyRotationDecision( member: PartyMember, context: PartyAbilityContext, ): RotationDecision | null { const friendlyTargets = context.friendlyTargets ?? (context.fallenAlly || context.allyNeedsDispel || (context.lowestHealthFraction ?? 1) < 1 ? [{ id: "rotation-friendly-target", health: (context.lowestHealthFraction ?? 1) * 100, maxHealth: 100, fallen: context.fallenAlly, needsDispel: context.allyNeedsDispel, }] : []); const maximumResources = Object.fromEntries( [...new Set([...Object.keys(member.resourcePools), member.resourceType])] .map((resource) => [resource, companionResourceMaximum(member, resource)]), ); const learned = learnedAbilities(member, context.now); const readyAbilities = learned.filter((ability) => !(member.classId === "witch-doctor" && member.specializationId === "witch-doctor:brewing" && member.role === "healer" && coaWitchDoctorIngredientIdForAbility(ability) !== null)).filter((ability) => partyAbilityIsReady( member, ability, context.now, Boolean(context.nextHealingSpellFree && ability.effects.some((effect) => effect.kind === "heal")), )); const linkedKnightPunishment = member.classId === "rom-knight" && member.secondaryClassId === "rom-rogue" ? readyAbilities.find((ability) => ability.source === "runewaker" && ability.classId === "rom-knight" && ability.secondaryClassId === "rom-rogue" && ability.dbcSpellId === 499909 && (context.canUseAbility?.(ability) ?? true)) : null; const rotationAbilities = linkedKnightPunishment ? readyAbilities.filter((ability) => ability.dbcSpellId !== 490151) : readyAbilities; const reapingRank = member.classId === "death-knight" ? Math.max(0, Math.trunc(member.talentRanks["talent-2001"] ?? 0)) : 0; const activeConditionalAbilityNames = new Set(context.availableConditionalAbilityNames ?? []); const pestilence = learned.find((ability) => ability.name === "Pestilence"); const targetPosition = context.target ? getMobPosition(context.target.id) : null; const hasNearbyPestilenceTarget = Boolean(targetPosition && pestilence && reapingRank > 0 && member.specializationId === "death-knight:blood" && member.runes.some((rune) => rune.baseType === "blood" && rune.readyAt <= context.now) && Object.values(useCombatStore.getState().mobs).some((mob) => { if (mob.id === context.target?.id || !mob.engaged || mob.dead) return false; const position = getMobPosition(mob.id); if (!position) return false; const dx = position[0] - targetPosition[0]; const dy = position[1] - targetPosition[1]; const dz = position[2] - targetPosition[2]; const radius = pestilence!.radius ?? 10; return dx * dx + dy * dy + dz * dz <= radius * radius && combatHasLineOfSight(targetPosition, position); })); const hasCasterOwnedDiseaseOnAnchor = Boolean(context.target && useCombatStore.getState().effects.some((effect) => ( effect.kind === "dot" && effect.sourceActorId === member.id && effect.targetId === context.target?.id && (effect.tag === "blood-plague" || effect.tag === "frost-fever") && effect.remainingTicks > 0 && effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs > context.now ))); if (hasNearbyPestilenceTarget && hasCasterOwnedDiseaseOnAnchor) { activeConditionalAbilityNames.add("Pestilence"); } const decision = evaluateCombatRotation({ now: context.now, classId: member.classId, secondaryClassId: member.secondaryClassId, specializationId: member.specializationId, specialization: member.specialization, role: member.role, statRuleset: member.statRuleset, level: member.level, health: member.health, maxHealth: member.maxHealth, selfId: member.id, resources: member.resourcePools, maximumResources, primaryResource: member.resourceType, comboPoints: member.comboPoints, comboTargetId: member.comboTargetId, runes: member.runes, deathKnightReapingRank: reapingRank, abilities: rotationAbilities, learnedAbilities: learned, manaCostForAbility: (ability) => partyAbilityCosts(member, ability, context.now) .filter((cost) => cost.resource === "mana") .reduce((total, cost) => total + cost.amount, 0), knownDamagingAbilityNames: new Set(learned.filter((ability) => { const categories = aiHintsForAbility(ability).categories; return categories.includes("damage") || categories.includes("execute"); }).map((ability) => ability.name)), hostileTarget: context.target, friendlyTargets, hostileCount: context.hostileCount, targetIsLoose: context.targetIsLoose, hasDangerousThreat: context.hasDangerousThreat, enemyUsesMeleeAttacks: context.enemyUsesMeleeAttacks, incomingMeleeDanger: context.incomingMeleeDanger, needsEscape: context.needsEscape, moving: context.moving, requiredControl: context.requiredControl, maintainedAbilityIds: context.maintainedAbilityIds, maintainedPeriodicEffects: useCombatStore.getState().effects.map((effect) => ({ abilityId: effect.sourceAbilityId, sourceActorId: effect.sourceActorId, targetId: effect.targetId ?? "__player__", remainingMs: Math.max(0, effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs - context.now), })), activeAuraExclusiveGroups: context.activeAuraExclusiveGroups, activeSummonAbilityIds: context.activeSummonAbilityIds, hasActivePet: context.hasActivePet, partyResourceDeficit: context.partyResourceDeficit, availableConditionalAbilityNames: activeConditionalAbilityNames.size > 0 ? activeConditionalAbilityNames : undefined, canUseAbility: context.canUseAbility, cooldownPolicy: currentCompanionCooldownPolicy(), incomingDefensiveMechanic: context.incomingDefensiveMechanic, }); return decision && shouldReserveForHigherPriority(member, learned, decision, context.now) ? null : decision; } export function choosePartyAbility( member: PartyMember, context: PartyAbilityContext, ): AbilityDefinition | null { return choosePartyRotationDecision(member, context)?.ability ?? null; } export function commitPartyAbilityUse( member: PartyMember, ability: AbilityDefinition, now: number, options: { readonly ignoreResourceCosts?: boolean } = {}, ): PartyMember | null { if (!partyAbilityIsReady(member, ability, now, options.ignoreResourceCosts)) return null; const resourcePools = { ...member.resourcePools }; const lastResourceSpentAt = { ...member.lastResourceSpentAt }; let resource = member.resource; let health = member.health; for (const cost of options.ignoreResourceCosts ? [] : partyAbilityCosts(member, ability, now)) { const amount = cost.amount; if (cost.resource === "health") { health -= amount; continue; } const current = currentResource(member, cost.resource); resourcePools[cost.resource] = Math.max(0, current - amount); if (amount > 0) lastResourceSpentAt[cost.resource] = now; if (cost.resource === member.resourceType) resource = resourcePools[cost.resource] ?? resource; } const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null; const runes = runeCost ? spendDeathKnightRunes(member.runes, runeCost, now) : member.runes; if (!runes) return null; const romCooldownOverride = partyRomPriestChampionCooldownOverride(member, ability); const cooldownMs = member.classId === "mage" ? Math.max(0, ability.cooldownMs - wow335MageImprovedCounterspellCooldownReductionMs( member.talentRanks[WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID] ?? 0, ability.name, ) - wow335MageImprovedFireBlastCooldownReductionMs( member.talentRanks[WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID] ?? 0, ability.name, )) : romCooldownOverride ?? ability.cooldownMs; const knightWarriorCooldownReductionMs = partyKnightWarriorWhirlwindAdjustment(member, ability).cooldownReductionMs; const warriorDruidCooldownReductionMs = partyWarriorDruidWhirlwindAdjustment(member, ability).cooldownReductionMs; const knightCompassionCooldownReductionMs = partyKnightCompassionCooldownReductionMs(member, ability); const adjustedCooldownMs = Math.max(0, cooldownMs - knightWarriorCooldownReductionMs - warriorDruidCooldownReductionMs - knightCompassionCooldownReductionMs); const cooldowns = { ...member.cooldowns }; if (romCooldownOverride !== null) delete cooldowns[ability.id]; if (adjustedCooldownMs > 0) cooldowns[ability.id] = now + adjustedCooldownMs; return { ...member, health, resource, resourcePools, lastResourceSpentAt, runes, cooldowns, globalCooldownEndsAt: ability.gcdMs > 0 ? now + ability.gcdMs : member.globalCooldownEndsAt, tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt") ? now + Math.max(8_000, ability.cooldownMs) : member.tauntReadyAt, }; } export function partyAbilityAmount( member: PartyMember, ability: AbilityDefinition, effect: AbilityEffect, poisonDoses = 0, intellectOverride?: number, effectiveNativeStats?: RomNativeDerivedStats, nativeTargetContext?: RomNativeDamageTargetContext, disabledWeaponChannels?: ReadonlySet, ): number { const sourceIntellect = intellectOverride ?? deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", ).intellect; if (effect.kind === "finisher-damage" && effect.poison) { const consumedDoses = Math.max(0, Math.min( effect.poison.maximumDoses, Math.floor(poisonDoses), )); return equipmentAdjustedCombatAmount( { coefficient: 0, basePoints: effect.poison.basePointsPerDose * consumedDoses, dieSides: 1, attackPowerCoefficient: effect.poison.attackPowerCoefficientPerDose * consumedDoses, }, member.level, member.gearStats, member.classId, ability, 0, sourceIntellect, member.raceId, member.gender, member.statRuleset ?? "wow", member.secondaryClassId, undefined, undefined, disabledWeaponChannels, ); } const coefficient = "coefficient" in effect ? effect.coefficient : effect.kind === "finisher-damage" ? effect.baseCoefficient + effect.perComboCoefficient * member.comboPoints : 0; const baseAmount = equipmentAdjustedCombatAmount( "coefficient" in effect ? effect : coefficient, member.level, member.gearStats, member.classId, ability, 0, sourceIntellect, member.raceId, member.gender, member.statRuleset ?? "wow", member.secondaryClassId, effectiveNativeStats, nativeTargetContext, disabledWeaponChannels, ) + (effect.kind === "damage" ? effect.bonusFlat ?? 0 : 0); if (member.classId === "druid" && (effect.kind === "damage" || effect.kind === "dot")) { return applyTalentModifierValue( baseAmount, talentModifiersForRanks(member.talentRanks, "druid"), "damage-percent", { abilityName: ability.name }, ); } if (member.classId === "mage" && effect.kind === "damage") { return wow335MageImprovedFireBlastDamage( baseAmount, member.talentRanks[WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID] ?? 0, ability.name, ); } return baseAmount; }