Update Healer Man server

This commit is contained in:
phenom committed 2026-09-19 21:01:51 -04:00
1 parent f8314e7de1
commit 1fedb9368d
76 files changed
+20556 -2523

No files matched your search

+72 -15
View File
@@ -6,8 +6,13 @@ import { channelAmountEffects } from "./combatChannelAmounts";
import { spendDeathKnightRunes } from "./combatResources";
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
import type { PartyRole } from "./partyRoles";
import {
currentCompanionCooldownPolicy,
type CompanionCooldownMechanicRule,
type CompanionCooldownPolicy,
} from "./companionCooldownPolicy";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v4";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v5-ready-five";
export interface RotationProfile {
readonly id: string;
@@ -22,6 +27,7 @@ export interface RotationFriendlyTarget {
readonly maxHealth: number;
readonly needsDispel?: boolean;
readonly fallen?: boolean;
readonly role?: PartyRole;
}
export interface RotationHostileTarget {
@@ -67,6 +73,8 @@ export interface RotationContext {
/** Proc/state-gated actions that the caller has positively made available. */
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
readonly cooldownPolicy?: CompanionCooldownPolicy;
readonly incomingDefensiveMechanic?: CompanionCooldownMechanicRule | null;
}
export interface RotationDecision {
@@ -92,17 +100,17 @@ const profile = (
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Execute", "Slam", "Heroic Strike"]),
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "Bloodrage", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "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", "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", "Kill Shot", "Chimera Shot", "Aimed Shot", "Serpent Sting", "Arcane Shot", "Steady Shot"]),
profile("hunter:marksmanship", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Misdirection", "Rapid Fire", "Readiness", "Kill 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", ["Envenom", "Rupture", "Mutilate", "Eviscerate", "Sinister Strike"]),
profile("rogue:assassination", "rogue", ["Deadly Poison", "Instant Poison", "Tricks of the Trade", "Cold Blood", "Envenom", "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", "Penance", "Power Word: Shield", "Flash Heal", "Greater Heal", "Renew"]),
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"]),
@@ -112,7 +120,7 @@ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.free
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", "Mage Armor", "Ice Armor", "Frost Armor", "Living Bomb", "Fireball", "Scorch", "Fire Blast"]),
profile("mage:fire", "mage", ["Molten Armor", "Combustion", "Mirror Image", "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"]),
@@ -133,6 +141,14 @@ const CONDITIONAL_ABILITY_NAMES = new Set([
"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 TARGET_HEALTH_GATES = new Map<string, number>([
["Drain Soul", 0.25],
["Hammer of Wrath", 0.2],
@@ -275,6 +291,10 @@ function targetForAbility(
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) => (
@@ -337,9 +357,12 @@ function scoreAbility(
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 defensive = categories.has("mitigation") || categories.has("heal");
const offensiveMajorCooldown = ability.cooldownMs >= 60_000 && !defensive
&& (damaging || categories.has("buff") || categories.has("summon") || categories.has("pet"));
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
@@ -374,7 +397,7 @@ function scoreAbility(
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) return null;
if (!damaging && !namedDefensiveCooldown) return null;
}
if (categories.has("interrupt")) {
if (!context.hostileTarget?.activeCast?.interruptible) {
@@ -388,6 +411,15 @@ function scoreAbility(
return { ability, score: 1_017_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? null };
}
if (categories.has("threat") && !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,
@@ -398,6 +430,25 @@ function scoreAbility(
};
}
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.
@@ -426,7 +477,11 @@ function scoreAbility(
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 && !durableTarget) return 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;
@@ -455,7 +510,6 @@ function scoreAbility(
ruleId = `profile-${slug(ability.name)}`;
reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`;
}
if (context.role === "healer") {
const healing = actionableHealing
&& (healingAbilityCanAidOthers(ability) || lowest?.id === context.selfId);
@@ -468,7 +522,8 @@ function scoreAbility(
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")) {
if (lowestHealth >= 0.8 && selfHealth >= 0.8) return null;
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;
@@ -483,8 +538,10 @@ function scoreAbility(
}
} else {
if (categories.has("mitigation")) {
if (!maintenanceBuff && selfHealth >= 0.55) return null;
if (selfHealth < 0.55) score += selfHealth < 0.35 ? 180_000 : 140_000;
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;