import type { ClassId } from "../app/characterCatalog"; import type { AbilityDefinition, ResourceType } from "./abilityCatalog"; import { coaBarbarianDecapitateTargetAllowed } from "./coaBarbarianExecuteRuntime"; import { aiHintsForAbility, canAbilityCastWhileMoving, runeCostForAbility } from "./abilityRules"; import type { DeathKnightRuneState } from "./combatActors"; import { channelAmountEffects } from "./combatChannelAmounts"; import { spendDeathKnightRunes } from "./combatResources"; import { healingAbilityCanAidOthers } from "./healingAbilityRules"; import type { PartyRole } from "./partyRoles"; import type { CharacterStatRuleset } from "./characterStatRuleset"; import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "./romFiveRotation"; import { currentCompanionCooldownPolicy, type CompanionCooldownMechanicRule, type CompanionCooldownPolicy, } from "./companionCooldownPolicy"; export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v8-native-rom-healer-reserve"; export interface RotationProfile { readonly id: string; readonly specializationId: string; readonly classId: ClassId; readonly abilityPriority: readonly string[]; } export interface RotationFriendlyTarget { readonly id: string; readonly health: number; readonly maxHealth: number; readonly needsDispel?: boolean; readonly fallen?: boolean; readonly role?: PartyRole; } export interface RotationHostileTarget { readonly id: string; readonly health: number; readonly maxHealth: number; readonly boss?: boolean; readonly healthMultiplier?: number; readonly activeCast?: { readonly interruptible?: boolean } | null; } export interface RotationContext { readonly now: number; readonly classId: ClassId; readonly secondaryClassId?: ClassId | null; readonly statRuleset?: CharacterStatRuleset; readonly specializationId?: string | null; readonly specialization?: string | null; readonly role: PartyRole; readonly level: number; readonly health: number; readonly maxHealth: number; readonly selfId?: string; readonly resources: Readonly>>; readonly maximumResources: Readonly>>; readonly primaryResource: ResourceType; readonly comboPoints: number; readonly comboTargetId: string | null; readonly runes: readonly DeathKnightRuneState[]; /** Learned Reaping rank, used only to expose its otherwise-omitted Blood Strike trigger fallback. */ readonly deathKnightReapingRank?: number; readonly abilities: readonly AbilityDefinition[]; /** Full learned kit before affordability/cooldown filtering; resource reservations need this list. */ readonly learnedAbilities?: readonly AbilityDefinition[]; /** Current effective Mana cost after actor-specific aura/build adjustments. */ readonly manaCostForAbility?: (ability: AbilityDefinition) => number; /** Learned actions capable of hostile damage, including unavailable ones. */ readonly knownDamagingAbilityNames?: ReadonlySet; readonly hostileTarget: RotationHostileTarget | null; readonly friendlyTargets?: readonly RotationFriendlyTarget[]; readonly hostileCount?: number; readonly targetIsLoose?: boolean; readonly hasDangerousThreat?: boolean; /** The current hostile can make ordinary melee attacks against this actor. */ readonly enemyUsesMeleeAttacks?: boolean; /** A telegraphed high-impact melee attack is about to hit this actor. */ readonly incomingMeleeDanger?: boolean; /** The actor should create distance immediately rather than keep casting. */ readonly needsEscape?: boolean; readonly requiredControl?: boolean; readonly moving?: boolean; readonly maintainedAbilityIds?: ReadonlySet; readonly maintainedPeriodicEffects?: readonly { readonly abilityId: string; readonly sourceActorId: string; readonly targetId: string; readonly remainingMs: number; }[]; readonly activeAuraExclusiveGroups?: ReadonlySet; readonly activeSummonAbilityIds?: ReadonlySet; readonly hasActivePet?: boolean; /** True only when an in-range living ally has a depleted pool restored by this party-scoped effect. */ readonly partyResourceDeficit?: (ability: AbilityDefinition) => boolean; /** Proc/state-gated actions that the caller has positively made available. */ readonly availableConditionalAbilityNames?: ReadonlySet; readonly canUseAbility?: (ability: AbilityDefinition) => boolean; readonly cooldownPolicy?: CompanionCooldownPolicy; readonly incomingDefensiveMechanic?: CompanionCooldownMechanicRule | null; } export interface RotationDecision { readonly ability: AbilityDefinition; readonly targetId: string | null; readonly profileId: string; readonly ruleId: string; readonly reason: string; } const profile = ( specializationId: string, classId: ClassId, abilityPriority: readonly string[], ): RotationProfile => Object.freeze({ id: `classic:${specializationId}`, specializationId, classId, abilityPriority: Object.freeze(abilityPriority), }); /** Playerbots priorities translated to the executable Healer Man spell model. */ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([ profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Victory Rush", "Execute", "Slam", "Heroic Strike"]), profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Victory Rush", "Execute", "Sunder Armor", "Heroic Strike"]), profile("warrior:protection", "warrior", ["Defensive Stance", "Shield Block", "Last Stand", "Shield Wall", "Enraged Regeneration", "Taunt", "Shield Bash", "Bloodrage", "Shield Slam", "Revenge", "Victory Rush", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]), profile("paladin:holy", "paladin", ["Concentration Aura", "Devotion Aura", "Blessing of Might", "Seal of Wisdom", "Seal of Light", "Seal of Righteousness", "Holy Shock", "Flash of Light", "Holy Light", "Sacred Shield", "Judgement of Light"]), profile("paladin:protection", "paladin", ["Devotion Aura", "Blessing of Might", "Seal of Vengeance", "Seal of Corruption", "Seal of Wisdom", "Seal of Righteousness", "Hammer of the Righteous", "Shield of Righteousness", "Holy Shield", "Consecration", "Judgement of Wisdom"]), profile("paladin:retribution", "paladin", ["Retribution Aura", "Devotion Aura", "Blessing of Might", "Seal of Command", "Seal of Vengeance", "Seal of Corruption", "Seal of Righteousness", "Divine Storm", "Crusader Strike", "Judgement of Wisdom", "Consecration", "Exorcism", "Hammer of Wrath"]), profile("hunter:beast-mastery", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Command", "Bestial Wrath", "Serpent Sting", "Arcane Shot", "Multi-Shot", "Steady Shot"]), profile("hunter:marksmanship", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Misdirection", "Rapid Fire", "Readiness", "Kill Shot", "Volley", "Multi-Shot", "Chimera Shot", "Aimed Shot", "Serpent Sting", "Arcane Shot", "Steady Shot", "Silencing Shot", "Disengage", "Feign Death"]), profile("hunter:survival", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Shot", "Explosive Shot", "Black Arrow", "Serpent Sting", "Aimed Shot", "Arcane Shot", "Steady Shot"]), profile("rogue:assassination", "rogue", ["Deadly Poison", "Instant Poison", "Tricks of the Trade", "Cold Blood", "Fan of Knives", "Envenom", "Shiv", "Rupture", "Mutilate", "Eviscerate", "Sinister Strike", "Kick", "Evasion", "Cloak of Shadows", "Vanish"]), profile("rogue:combat", "rogue", ["Killing Spree", "Blade Flurry", "Eviscerate", "Sinister Strike"]), profile("rogue:subtlety", "rogue", ["Shadow Dance", "Rupture", "Eviscerate", "Hemorrhage", "Backstab"]), profile("priest:discipline", "priest", ["Inner Fire", "Pain Suppression", "Inner Focus", "Power Infusion", "Shadowfiend", "Divine Hymn", "Penance", "Power Word: Shield", "Flash Heal", "Greater Heal", "Renew", "Dispel Magic", "Cure Disease", "Resurrection"]), profile("priest:holy", "priest", ["Inner Fire", "Circle of Healing", "Prayer of Mending", "Flash Heal", "Greater Heal", "Renew"]), profile("priest:shadow", "priest", ["Shadowform", "Inner Fire", "Vampiric Touch", "Devouring Plague", "Shadow Word: Pain", "Mind Blast", "Mind Flay", "Shadow Word: Death"]), profile("death-knight:blood", "death-knight", ["Frost Presence", "Blood Presence", "Heart Strike", "Death Strike", "Icy Touch", "Plague Strike", "Death Coil"]), profile("death-knight:frost", "death-knight", ["Blood Presence", "Icy Touch", "Plague Strike", "Howling Blast", "Frost Strike", "Obliterate"]), profile("death-knight:unholy", "death-knight", ["Unholy Presence", "Blood Presence", "Raise Dead", "Plague Strike", "Icy Touch", "Scourge Strike", "Death Coil", "Death and Decay"]), profile("shaman:elemental", "shaman", ["Flametongue Weapon", "Rockbiter Weapon", "Lightning Shield", "Flame Shock", "Lava Burst", "Chain Lightning", "Lightning Bolt"]), profile("shaman:enhancement", "shaman", ["Windfury Weapon", "Rockbiter Weapon", "Lightning Shield", "Feral Spirit", "Stormstrike", "Lava Lash", "Earth Shock", "Lightning Bolt"]), profile("shaman:restoration", "shaman", ["Water Shield", "Rockbiter Weapon", "Lightning Shield", "Riptide", "Chain Heal", "Lesser Healing Wave", "Healing Wave", "Earth Shield"]), profile("mage:arcane", "mage", ["Mage Armor", "Ice Armor", "Frost Armor", "Arcane Barrage", "Arcane Blast", "Arcane Missiles"]), profile("mage:fire", "mage", ["Molten Armor", "Combustion", "Mirror Image", "Flamestrike", "Living Bomb", "Fireball", "Scorch", "Fire Blast", "Counterspell", "Blink", "Ice Block", "Fire Ward", "Frost Ward"]), profile("mage:frost", "mage", ["Frost Armor", "Ice Armor", "Mage Armor", "Deep Freeze", "Frostbolt", "Ice Lance", "Cone of Cold"]), profile("warlock:affliction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felhunter", "Summon Voidwalker", "Summon Imp", "Haunt", "Unstable Affliction", "Corruption", "Curse of Agony", "Drain Soul", "Shadow Bolt"]), profile("warlock:demonology", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felguard", "Summon Voidwalker", "Summon Imp", "Metamorphosis", "Immolate", "Corruption", "Shadow Bolt"]), profile("warlock:destruction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Imp", "Chaos Bolt", "Conflagrate", "Immolate", "Incinerate", "Shadow Bolt"]), profile("druid:balance", "druid", ["Moonkin Form", "Starfall", "Moonfire", "Insect Swarm", "Starfire", "Wrath"]), profile("druid:feral-bear", "druid", ["Dire Bear Form", "Bear Form", "Mangle (Bear)", "Lacerate", "Swipe (Bear)", "Maul"]), profile("druid:feral-cat", "druid", ["Cat Form", "Rip", "Savage Roar", "Mangle (Cat)", "Rake", "Shred", "Claw"]), profile("druid:restoration", "druid", ["Tree of Life", "Wild Growth", "Swiftmend", "Lifebloom", "Rejuvenation", "Regrowth", "Healing Touch"]), ]); const PROFILE_BY_SPECIALIZATION = new Map( CLASSIC_ROTATION_PROFILES.map((entry) => [entry.specializationId, entry]), ); const CONDITIONAL_ABILITY_NAMES = new Set([ "Overpower", "Pestilence", "Revenge", "Victory Rush", ]); const DEFENSIVE_COOLDOWN_NAMES = new Set([ "Shield Block", "Last Stand", "Shield Wall", "Enraged Regeneration", "Pain Suppression", "Ice Block", "Fire Ward", "Frost Ward", "Evasion", "Cloak of Shadows", "Vanish", ]); const THREAT_REDIRECT_NAMES = new Set(["Misdirection", "Tricks of the Trade"]); const ESCAPE_MOVEMENT_NAMES = new Set(["Blink", "Disengage"]); const TARGET_HEALTH_GATES = new Map([ ["Drain Soul", 0.25], ["Hammer of Wrath", 0.2], ["Kill Shot", 0.2], ]); const MINIMUM_HOSTILE_TARGETS = new Map([ ["Pestilence", 2], ["Multi-Shot", 2], ["Volley", 3], ["Flamestrike", 3], ["Fan of Knives", 3], ]); export function minimumHostileTargetsForAbility(abilityName: string): number { return MINIMUM_HOSTILE_TARGETS.get(abilityName) ?? 1; } const BEAR_FORM_ACTION_NAMES = new Set([ "Bash", "Challenging Roar", "Demoralizing Roar", "Frenzied Regeneration", "Growl", "Lacerate", "Mangle (Bear)", "Maul", "Swipe (Bear)", ]); const CAT_FORM_ACTION_NAMES = new Set([ "Claw", "Cower", "Ferocious Bite", "Mangle (Cat)", "Pounce", "Rake", "Ravage", "Rip", "Savage Roar", "Shred", "Swipe (Cat)", "Tiger's Fury", ]); function slug(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); } export function rotationProfileFor( classId: ClassId, specializationId?: string | null, specialization?: string | null, ): RotationProfile | null { const explicit = specializationId ? PROFILE_BY_SPECIALIZATION.get(specializationId) : null; if (explicit?.classId === classId) return explicit; const inferred = specialization ? PROFILE_BY_SPECIALIZATION.get(`${classId}:${slug(specialization)}`) : null; return inferred?.classId === classId ? inferred : null; } function throughput(ability: AbilityDefinition, comboPoints: number): number { return ability.effects.reduce((total, effect) => { if (["damage", "heal", "shield", "dot", "hot", "execute"].includes(effect.kind) && "coefficient" in effect) { const basePoints = "basePoints" in effect ? effect.basePoints ?? 0 : 0; return total + Math.max(0, effect.coefficient) * 10 + Math.max(0, basePoints) * 0.01; } if (effect.kind === "finisher-damage") { return total + (effect.baseCoefficient + effect.perComboCoefficient * comboPoints) * 10; } return total; }, 0); } function isComboFinisher(ability: AbilityDefinition): boolean { return ability.effects.some((effect) => effect.kind === "finisher-damage" || (effect.kind === "combo" && effect.amount < 0)); } function isComboBuilder(ability: AbilityDefinition): boolean { return ability.effects.some((effect) => effect.kind === "combo" && effect.amount > 0); } function isPetSummon(ability: AbilityDefinition): boolean { return ability.effects.some((effect) => effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)); } export const PRECOMBAT_MINIMUM_BUFF_DURATION_MS = 60_000; function isPersistentPetPreparation( ability: AbilityDefinition, effect: Extract, ): boolean { if (["call", "resurrect", "tame"].includes(effect.action)) return true; return effect.action === "summon" && ability.cooldownMs < PRECOMBAT_MINIMUM_BUFF_DURATION_MS; } /** Long-lived self maintenance and permanent pets that should exist before the first pull. */ export function precombatPreparationEffects(ability: AbilityDefinition): readonly AbilityDefinition["effects"][number][] { return ability.effects.filter((effect) => ( effect.kind === "pet" ? isPersistentPetPreparation(ability, effect) : effect.kind === "apply-aura" && effect.aura.disposition === "buff" && (effect.aura.durationMs === null || effect.aura.durationMs >= PRECOMBAT_MINIMUM_BUFF_DURATION_MS) )); } export function abilityIsPrecombatPreparation(ability: AbilityDefinition): boolean { return !ability.passive && precombatPreparationEffects(ability).length > 0; } function isOffensiveSummon(ability: AbilityDefinition): boolean { return ability.effects.some((effect) => effect.kind === "summon" && effect.coefficient >= 0.15); } function isCombatMaintenanceAura(ability: AbilityDefinition): boolean { return ability.effects.some((effect) => effect.kind === "apply-aura" && effect.aura.disposition === "buff" && (effect.aura.durationMs === null || effect.aura.durationMs >= 15_000) && Boolean( effect.aura.modifiers?.length || effect.aura.absorbs?.length || effect.aura.procs?.length || effect.aura.controlImmunities?.length || effect.aura.reflectSpellChance || effect.aura.redirectDamagePercent || effect.aura.utilityTags?.includes("weapon-enchant"), )); } export function abilityHasRotationSemantics(ability: AbilityDefinition): boolean { return ability.effects.some((effect) => { if (effect.kind === "unsupported" || effect.kind === "scripted") return false; // Killing Spree's player-facing effect is intentionally a utility marker; // the cast creates a timed aura whose source script supplies the assaults. if (effect.kind === "utility") return ability.source === "wow335-progression" && ability.classId === "rogue" && ability.dbcSpellId === 51690 && ability.name === "Killing Spree"; if (effect.kind !== "apply-aura") return true; return Boolean( effect.control || effect.aura.modifiers?.length || effect.aura.absorbs?.length || effect.aura.procs?.length || effect.aura.utilityTags?.length || effect.aura.controlImmunities?.length || effect.aura.reflectSpellChance || effect.aura.redirectDamagePercent, ); }); } function targetForAbility( ability: AbilityDefinition, context: RotationContext, lowest: RotationFriendlyTarget | null, ): string | null { const categories = new Set(aiHintsForAbility(ability).categories); if (THREAT_REDIRECT_NAMES.has(ability.name)) { return context.friendlyTargets?.find((target) => target.role === "tank" && !target.fallen)?.id ?? context.selfId ?? null; } const casterOnly = ability.target === "self" || (ability.effects.some((effect) => ( "recipient" in effect && effect.recipient === "caster" )) && !ability.effects.some((effect) => ( "recipient" in effect && effect.recipient === "ability-target" ))); const hostileAction = (["damage", "execute", "debuff", "control", "interrupt", "taunt"] as const) .some((category) => categories.has(category)); if (casterOnly && !hostileAction) return context.selfId ?? null; if (ability.target === "friendly") return lowest?.id ?? null; if (ability.target === "any" && lowest && aiHintsForAbility(ability).categories.some((category) => ( category === "heal" || category === "dispel" || category === "resurrection" || category === "mitigation" ))) return lowest.id; // Self-centered hostile area skills (for example Thunder Clap) still need an // enemy anchor so execution can enumerate targets around the caster. if (ability.target === "self" && (ability.radius ?? 0) > 0 && (["damage", "execute", "debuff", "control", "interrupt", "taunt"] as const) .some((category) => categories.has(category))) { return context.hostileTarget?.id ?? null; } if (ability.target === "hostile" || ability.target === "any") return context.hostileTarget?.id ?? null; return null; } interface ScoredRotationAbility { readonly ability: AbilityDefinition; readonly score: number; readonly ruleId: string; readonly reason: string; readonly targetId: string | null; } function scoreAbility( ability: AbilityDefinition, context: RotationContext, rotationProfile: RotationProfile | null, lowest: RotationFriendlyTarget | null, ): ScoredRotationAbility | null { const hints = aiHintsForAbility(ability); const categories = new Set(hints.categories); const targetHealth = context.hostileTarget ? context.hostileTarget.health / Math.max(1, context.hostileTarget.maxHealth) : 1; const selfHealth = context.health / Math.max(1, context.maxHealth); const lowestHealth = lowest ? lowest.health / Math.max(1, lowest.maxHealth) : 1; const injured = (context.friendlyTargets ?? []).filter((target) => ( !target.fallen && target.health > 0 && target.health < target.maxHealth * 0.9 )); const resource = context.resources[context.primaryResource] ?? 0; const maximumResource = context.maximumResources[context.primaryResource] ?? 0; const resourceFraction = maximumResource > 0 ? resource / maximumResource : 1; const nativePeriodic = nativeRotationProfileFor(context) !== null && context.maintainedPeriodicEffects !== undefined && ability.effects.some((effect) => effect.kind === "dot" || effect.kind === "hot"); const maintained = (!nativePeriodic && (context.maintainedAbilityIds?.has(ability.id) ?? false)) || ability.effects.some((effect) => effect.kind === "apply-aura" && Boolean(effect.aura.exclusiveGroup && context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup))); const activeSummon = context.activeSummonAbilityIds?.has(ability.id) ?? false; const petSummon = isPetSummon(ability); const offensiveSummon = isOffensiveSummon(ability); const maintenanceBuff = isCombatMaintenanceAura(ability); const channelAmounts = ability.castMode === "channel" ? channelAmountEffects(ability, 1) : []; const damaging = categories.has("damage") || categories.has("execute") || channelAmounts.some(({ effect }) => effect.kind === "damage" || effect.kind === "dot"); const healingCategory = categories.has("heal") || categories.has("emergency-heal") || categories.has("area-heal"); const namedDefensiveCooldown = DEFENSIVE_COOLDOWN_NAMES.has(ability.name); const defensive = categories.has("mitigation") || categories.has("heal") || namedDefensiveCooldown; const cooldownPolicy = context.cooldownPolicy ?? currentCompanionCooldownPolicy(); const offensiveMajorCooldown = ability.cooldownMs >= cooldownPolicy.generic.offensiveMajorCooldownSeconds * 1_000 && !defensive && (damaging || categories.has("buff") || categories.has("summon") || categories.has("pet") || ability.effects.some((effect) => effect.kind === "cooldown-reset")); const durableTarget = Boolean( context.hostileTarget?.boss || (context.hostileTarget?.healthMultiplier ?? 1) > 1 || (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5, ); const profileIndex = rotationProfile?.abilityPriority.findIndex((name) => name === ability.name) ?? -1; const knownDamagingAbilityNames = context.knownDamagingAbilityNames ?? new Set(context.abilities.filter((candidate) => { const candidateCategories = aiHintsForAbility(candidate).categories; return candidateCategories.includes("damage") || candidateCategories.includes("execute"); }).map((candidate) => candidate.name)); const hasKnownProfileDamageAction = Boolean(rotationProfile?.abilityPriority.some((name) => ( knownDamagingAbilityNames.has(name) ))); if (!coaBarbarianDecapitateTargetAllowed(ability, context.hostileTarget)) return null; const targetHealthGate = TARGET_HEALTH_GATES.get(ability.name); if (targetHealthGate !== undefined && targetHealth > targetHealthGate) return null; if ((context.hostileCount ?? 1) < minimumHostileTargetsForAbility(ability.name)) return null; if (CONDITIONAL_ABILITY_NAMES.has(ability.name) && !context.availableConditionalAbilityNames?.has(ability.name)) return null; if (ability.name === "Victory Rush" && context.availableConditionalAbilityNames?.has("Victory Rush")) { return { ability, score: 1_016_500, ruleId: "reactive-improved-victory-rush", reason: "a parry opened the Victory Rush window", targetId: context.hostileTarget?.id ?? null, }; } if (context.classId === "druid") { const specializationId = rotationProfile?.specializationId; if (specializationId === "druid:feral-bear" && CAT_FORM_ACTION_NAMES.has(ability.name)) return null; if (specializationId === "druid:feral-cat" && BEAR_FORM_ACTION_NAMES.has(ability.name)) return null; if (specializationId && !specializationId.startsWith("druid:feral-") && (BEAR_FORM_ACTION_NAMES.has(ability.name) || CAT_FORM_ACTION_NAMES.has(ability.name))) return null; } if (categories.has("resurrection")) { const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen); return fallen ? { ability, score: 1_020_000, ruleId: "reactive-resurrection", reason: "fallen ally", targetId: fallen.id } : null; } if (categories.has("dispel")) { const dispel = (context.friendlyTargets ?? []).find((target) => target.needsDispel); if (dispel) return { ability, score: 1_019_000, ruleId: "reactive-dispel", reason: "ally needs dispel", targetId: dispel.id }; if (!damaging && !namedDefensiveCooldown) return null; } if (categories.has("interrupt")) { if (!context.hostileTarget?.activeCast?.interruptible) { if (!damaging) return null; } else { return { ability, score: 1_018_000, ruleId: "reactive-interrupt", reason: "interruptible enemy cast", targetId: context.hostileTarget.id }; } } if (categories.has("taunt")) { if (context.role !== "tank" || !context.targetIsLoose) return null; return { ability, score: 1_017_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? null }; } if (ability.name === "Inner Focus") { const lowAllies = (context.friendlyTargets ?? []).filter((target) => ( !target.fallen && target.health > 0 && target.health / Math.max(1, target.maxHealth) <= 0.6 )); const shouldPrepareHeal = context.role === "healer" && (lowestHealth <= 0.4 || lowAllies.length >= 2); return shouldPrepareHeal ? { ability, score: 1_018_500, ruleId: lowAllies.length >= 2 ? "prepare-area-heal" : "prepare-emergency-heal", reason: lowAllies.length >= 2 ? "multiple allies need empowered healing" : "an ally needs empowered healing", targetId: context.selfId ?? null, } : null; } if (ability.name === "Vanish") { const bossHasAggro = Boolean(context.hasDangerousThreat && context.hostileTarget?.boss); const nearDeath = selfHealth <= 0.2; return bossHasAggro || nearDeath ? { ability, score: nearDeath ? 1_017_500 : 1_016_750, ruleId: nearDeath ? "emergency-vanish" : "boss-threat-vanish", reason: nearDeath ? "reset threat and escape lethal danger" : "reset boss threat and escape", targetId: context.selfId ?? null, } : null; } if (ability.name === "Evasion") { const bossHasAggro = Boolean(context.hasDangerousThreat && context.hostileTarget?.boss); const lowAgainstMelee = selfHealth <= 0.4 && Boolean(context.enemyUsesMeleeAttacks); return bossHasAggro || lowAgainstMelee || context.incomingMeleeDanger ? { ability, score: context.incomingMeleeDanger ? 1_017_000 : 1_016_500, ruleId: context.incomingMeleeDanger ? "preemptive-melee-evasion" : "reactive-melee-evasion", reason: context.incomingMeleeDanger ? "avoid an imminent strong melee attack" : bossHasAggro ? "avoid boss attacks while holding threat" : "low health against a melee attacker", targetId: context.selfId ?? null, } : null; } if (ESCAPE_MOVEMENT_NAMES.has(ability.name)) { return context.needsEscape ? { ability, score: 1_016_250, ruleId: "reactive-danger-escape", reason: "create distance from immediate danger", targetId: context.selfId ?? null, } : null; } // Some defensive spells also reduce the target's threat (for example // Pain Suppression). Keep them eligible for mitigation scoring even when // the healer does not currently hold enemy attention. if (categories.has("threat") && !categories.has("mitigation") && !damaging && !maintenanceBuff) { if (THREAT_REDIRECT_NAMES.has(ability.name) && context.role !== "tank" && durableTarget) { return { ability, score: 1_015_500, ruleId: "preemptive-threat-redirect", reason: "redirect opening threat to the tank", targetId: targetForAbility(ability, context, lowest), }; } if (context.role === "tank" || !context.hasDangerousThreat) return null; return { ability, score: 1_015_000, ruleId: "reactive-threat-drop", reason: "non-tank has enemy attention", targetId: ability.target === "self" ? null : context.hostileTarget?.id ?? null, }; } if (categories.has("control") && !context.requiredControl && !damaging) return null; if (namedDefensiveCooldown && context.incomingDefensiveMechanic) { return { ability, score: 1_014_500 + (hints.priority ?? 0), ruleId: `preemptive-${context.incomingDefensiveMechanic.id}`, reason: `pre-empt ${context.incomingDefensiveMechanic.attackName}`, targetId: targetForAbility(ability, context, lowest), }; } if (categories.has("mitigation") && context.incomingDefensiveMechanic && ability.cooldownMs >= context.incomingDefensiveMechanic.minimumCooldownSeconds * 1_000) { return { ability, score: 1_014_000 + (hints.priority ?? 0), ruleId: `preemptive-${context.incomingDefensiveMechanic.id}`, reason: `pre-empt ${context.incomingDefensiveMechanic.attackName}`, targetId: targetForAbility(ability, context, lowest), }; } // A friendly heal without an injured recipient must never fall through to // the hostile target. Hybrid damage builds otherwise refresh HoTs on enemies // forever because those auras also look like maintenance buffs. const actionableHealing = healingCategory && !maintenanceBuff && !petSummon && !offensiveSummon; if (maintenanceBuff && healingCategory && (ability.target === "friendly" || ability.target === "any") && !lowest) return null; if (actionableHealing && !damaging) { const healingTargetHealth = ability.target === "self" && !(nativeRotationProfileFor(context) && healingAbilityCanAidOthers(ability)) ? selfHealth : lowestHealth; if ((ability.target === "friendly" || ability.target === "any") && !lowest) return null; if (healingTargetHealth >= 0.9) return null; } if (nativeRotationProfileFor(context)?.role === "healer" && ability.name === "Urgent Heal" && lowest && lowestHealth <= 0.3) { return { ability, score: 1_015_500, ruleId: "native-emergency-heal", reason: "a critically injured ally needs the fastest available direct heal", targetId: lowest.id }; } // Pet stance and recall buttons require an explicit positioning/order intent; // treating them as maintenance actions otherwise starves real attacks. if (categories.has("pet") && !petSummon && !categories.has("summon") && !damaging && !categories.has("buff") && !categories.has("heal")) return null; if (petSummon && context.hasActivePet) return null; // Scouting objects, portals, ritual objects, and other zero-pressure DBC // summons are valid player buttons but are not autonomous combat actions. if (categories.has("summon") && !damaging && !petSummon && !offensiveSummon && !maintenanceBuff) return null; if (categories.has("buff") && !damaging && !defensive && !maintenanceBuff && !categories.has("resource") && !petSummon && !offensiveSummon && !(offensiveMajorCooldown && profileIndex >= 0)) return null; if (categories.has("movement") && !damaging && !context.moving) return null; if (context.requiredControl && categories.has("control")) { return { ability, score: 1_016_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null }; } if (offensiveMajorCooldown && cooldownPolicy.generic.conserveOffensiveCooldownsForDurableTargets && !durableTarget) return null; if (namedDefensiveCooldown && !context.incomingDefensiveMechanic) { const threshold = cooldownPolicy.generic.defensiveHealthPercent / 100; if (selfHealth >= threshold && lowestHealth >= threshold) return null; } if (maintained && (categories.has("debuff") || categories.has("buff"))) return null; if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null; if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null; // A specialization profile is also an allow-list for utility GCDs once the // build has a real damage action. Reactive interrupts, taunts, threat drops, // and encounter-requested control already returned above; letting any other // imported debuff/control action through here makes characters spam buttons // such as Expose Armor and Demoralizing Shout instead of their rotation. if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction && context.role !== "healer" && profileIndex < 0 && (categories.has("debuff") || categories.has("control") || categories.has("interrupt"))) return null; if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction && context.role !== "healer" && profileIndex < 0 && damaging) return null; if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null; // Shiv may still be needed to add a missing poison dose at five points; // partyRuntime's dose gate decides whether that maintenance hit is useful. if (isComboBuilder(ability) && ability.name !== "Shiv" && context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null; let score = hints.priority ?? 0; let ruleId = "semantic-fallback"; let reason = "shared semantic priority"; const maintenanceScore = rotationProfile === null ? 5_200 : profileIndex >= 0 ? 7_000 : 2_000; const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800; if (profileIndex >= 0) { score += context.role === "healer" || context.level < 10 ? 4_000 - profileIndex * 120 : 100_000 - profileIndex * 5_000; ruleId = `profile-${slug(ability.name)}`; reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`; } if (context.role === "healer") { const healing = actionableHealing && (healingAbilityCanAidOthers(ability) || lowest?.id === context.selfId); if (!healing && !categories.has("mitigation") && !damaging && !categories.has("resource") && !maintenanceBuff && !petSummon && !offensiveSummon) return null; if (healing) { if (!lowest || lowestHealth >= 0.9) return null; score += lowestHealth < 0.3 ? 7_000 : lowestHealth < 0.6 ? 5_000 : 3_000; if (categories.has("emergency-heal")) score += lowestHealth < 0.3 ? 1_500 : -1_000; if (categories.has("area-heal")) score += injured.length >= 3 ? 6_500 : -1_200; score += Math.min(500, throughput(ability, context.comboPoints) * 10); } else if (categories.has("mitigation")) { const threshold = cooldownPolicy.generic.healerMitigationHealthPercent / 100; if (lowestHealth >= threshold && selfHealth >= threshold) return null; score += 2_500; } else if (maintenanceBuff) { score += context.maintainedAbilityIds === undefined ? 1_600 : maintenanceScore; ruleId = "maintenance-buff"; reason = "missing maintenance buff"; } else if (petSummon || offensiveSummon) { score += context.activeSummonAbilityIds === undefined ? 1_400 : summonScore; ruleId = "maintenance-summon"; reason = "missing pet or summon"; } else if (damaging) { score += injured.length ? -3_000 : 300; } } else { if (categories.has("mitigation")) { const defensiveThreshold = cooldownPolicy.generic.defensiveHealthPercent / 100; const emergencyThreshold = cooldownPolicy.generic.emergencyHealthPercent / 100; if (!maintenanceBuff && selfHealth >= defensiveThreshold) return null; if (selfHealth < defensiveThreshold) score += selfHealth < emergencyThreshold ? 180_000 : 140_000; } if (categories.has("execute")) { if (targetHealth > 0.2) return null; score += 6_000; ruleId = "execute"; reason = "target below twenty percent"; } if (categories.has("debuff")) score += 2_000; if (categories.has("buff")) { score += maintenanceBuff && context.maintainedAbilityIds !== undefined ? maintenanceScore : 1_600; if (!damaging && !defensive && maintenanceBuff) { ruleId = "maintenance-buff"; reason = "missing maintenance buff"; } } if (petSummon || offensiveSummon) { score += damaging || context.activeSummonAbilityIds === undefined ? 1_400 : summonScore; if (!damaging) { ruleId = "maintenance-summon"; reason = "missing pet or summon"; } } if (categories.has("resource")) { const partyResourceDeficit = ability.effects.some((effect) => ( effect.kind === "resource" && effect.scope === "party" )) && (context.partyResourceDeficit?.(ability) ?? false); if (partyResourceDeficit) { score += 2_600; ruleId = "party-resource-restoration"; reason = "an in-range ally has a depleted resource pool"; } else { score += resourceFraction < 0.3 ? 2_600 : -1_000; } } if (damaging) score += 1_000 + throughput(ability, context.comboPoints) * 10; if (isComboFinisher(ability)) score += 2_400; if (isComboBuilder(ability)) score += context.comboPoints < 4 ? 1_400 : 0; if ((context.hostileCount ?? 1) >= 3) score += categories.has("area-damage") ? 2_000 : -300; else if ((context.hostileCount ?? 1) === 2) score += categories.has("area-damage") ? 900 : 0; else if (categories.has("area-damage")) score -= 300; } if (score <= 0) return null; return { ability, score, ruleId, reason, targetId: targetForAbility(ability, context, lowest) }; } function rotationManaCost(ability: AbilityDefinition, context: RotationContext): number { if (context.manaCostForAbility) return context.manaCostForAbility(ability); return (ability.costs?.length ? ability.costs : [ability.cost]) .filter((cost) => cost.resource === "mana") .reduce((total, cost) => total + Math.max(0, cost.amount), 0); } function nativeEarlyPriestHealManaReserve(context: RotationContext): number { if (context.statRuleset !== "rom" || context.classId !== "rom-priest" || context.role !== "healer" || context.level >= 10) return 0; return (context.learnedAbilities ?? context.abilities) .filter((ability) => !ability.passive && (ability.target === "friendly" || ability.target === "any") && healingAbilityCanAidOthers(ability) && ability.effects.some((effect) => effect.kind === "heal")) .map((ability) => rotationManaCost(ability, context)) .filter((cost) => Number.isFinite(cost) && cost > 0) .reduce((minimum, cost) => Math.min(minimum, cost), Number.POSITIVE_INFINITY); } /** True when spending this hostile ability would consume the early RoM Priest's learned direct-heal reserve. */ export function nativeEarlyPriestHealReserveBlocksAbility( ability: AbilityDefinition, context: RotationContext, ): boolean { const reserve = nativeEarlyPriestHealManaReserve(context); if (!Number.isFinite(reserve) || reserve <= 0 || ability.target !== "hostile" || !aiHintsForAbility(ability).categories.some((category) => category === "damage" || category === "execute")) { return false; } const manaCost = rotationManaCost(ability, context); if (!Number.isFinite(manaCost) || manaCost <= 0) return false; return (context.resources.mana ?? 0) - manaCost < reserve; } export function evaluateCombatRotation(context: RotationContext): RotationDecision | null { const baseRotationProfile = nativeRotationProfileFor(context) ?? rotationProfileFor( context.classId, context.specializationId, context.specialization, ); const hasSpendableBaseBloodRune = context.runes.some((rune) => ( rune.baseType === "blood" && rune.readyAt <= context.now )); const reapingRank = Math.max(0, Math.trunc(context.deathKnightReapingRank ?? 0)); const reapingTriggerFallbacks: string[] = []; if (baseRotationProfile?.specializationId === "death-knight:blood" && reapingRank > 0 && hasSpendableBaseBloodRune) { reapingTriggerFallbacks.push("Blood Strike"); if (context.availableConditionalAbilityNames?.has("Pestilence")) { reapingTriggerFallbacks.push("Pestilence"); } } const rotationProfile = baseRotationProfile && reapingTriggerFallbacks.length > 0 ? { ...baseRotationProfile, // Keep the normal Blood priority intact. Reaping triggers are final // fallbacks, with Blood Strike preferred over Pestilence. abilityPriority: Object.freeze([...baseRotationProfile.abilityPriority, ...reapingTriggerFallbacks]), } : baseRotationProfile; const lowest = [...(context.friendlyTargets ?? [])] .filter((target) => target.health > 0 && !target.fallen) .sort((left, right) => ( left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth) || left.id.localeCompare(right.id) ))[0] ?? null; const decision = context.abilities .filter((ability) => !ability.passive) .filter((ability) => abilityHasRotationSemantics(ability) || (ability.name === "Pestilence" && reapingRank > 0 && context.availableConditionalAbilityNames?.has("Pestilence") === true)) .filter((ability) => !context.moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability)) .filter((ability) => { if (context.classId !== "death-knight") return true; const runeCost = runeCostForAbility(ability); return !runeCost || spendDeathKnightRunes(context.runes, runeCost, context.now) !== null; }) .filter((ability) => context.canUseAbility?.(ability) ?? true) .filter((ability) => !nativeEarlyPriestHealReserveBlocksAbility(ability, context)) .filter((ability) => nativeRotationAbilityAllowed(ability, context)) .map((ability) => scoreAbility(ability, context, rotationProfile, lowest)) .filter((candidate): candidate is ScoredRotationAbility => candidate !== null) .sort((left, right) => right.score - left.score || left.ability.cooldownMs - right.ability.cooldownMs || left.ability.id.localeCompare(right.ability.id))[0]; return decision ? { ability: decision.ability, targetId: decision.targetId, profileId: rotationProfile?.id ?? `semantic:${context.classId}`, ruleId: decision.ruleId, reason: decision.reason, } : null; }