Release Healer Man 0.1.49
This commit is contained in:
1 parent
39425f2494
commit
612170afd0
91 files changed
+2278
-263
No files matched your search
@@ -0,0 +1,484 @@
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import type { AbilityDefinition, ResourceType } from "./abilityCatalog";
|
||||
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";
|
||||
|
||||
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v1";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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<Partial<Record<ResourceType, number>>>;
|
||||
readonly maximumResources: Readonly<Partial<Record<ResourceType, number>>>;
|
||||
readonly primaryResource: ResourceType;
|
||||
readonly comboPoints: number;
|
||||
readonly comboTargetId: string | null;
|
||||
readonly runes: readonly DeathKnightRuneState[];
|
||||
readonly abilities: readonly AbilityDefinition[];
|
||||
readonly hostileTarget: RotationHostileTarget | null;
|
||||
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
|
||||
readonly hostileCount?: number;
|
||||
readonly targetIsLoose?: boolean;
|
||||
readonly hasDangerousThreat?: boolean;
|
||||
readonly requiredControl?: boolean;
|
||||
readonly moving?: boolean;
|
||||
readonly maintainedAbilityIds?: ReadonlySet<string>;
|
||||
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
||||
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
||||
readonly hasActivePet?: boolean;
|
||||
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
||||
}
|
||||
|
||||
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", "Mortal Strike", "Rend", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "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: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: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: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", "Howling Blast", "Obliterate", "Frost Strike", "Icy Touch", "Plague Strike"]),
|
||||
profile("death-knight:unholy", "death-knight", ["Unholy Presence", "Blood Presence", "Raise Dead", "Scourge Strike", "Death and Decay", "Plague Strike", "Icy Touch", "Death Coil"]),
|
||||
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", "Mage Armor", "Ice Armor", "Frost Armor", "Living Bomb", "Pyroblast", "Fireball", "Scorch", "Fire Blast"]),
|
||||
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]),
|
||||
);
|
||||
|
||||
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<AbilityDefinition["effects"][number], { kind: "pet" }>,
|
||||
): 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" || effect.kind === "utility") return false;
|
||||
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);
|
||||
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 maintained = (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 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 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;
|
||||
|
||||
if (categories.has("resurrection")) {
|
||||
const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen);
|
||||
return fallen ? { ability, score: 20_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: 19_000, ruleId: "reactive-dispel", reason: "ally needs dispel", targetId: dispel.id };
|
||||
if (!damaging) return null;
|
||||
}
|
||||
if (categories.has("interrupt")) {
|
||||
if (!context.hostileTarget?.activeCast?.interruptible) {
|
||||
if (!damaging) return null;
|
||||
} else {
|
||||
return { ability, score: 18_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: 17_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? null };
|
||||
}
|
||||
if (categories.has("threat") && !damaging && !maintenanceBuff) {
|
||||
if (context.role === "tank" || !context.hasDangerousThreat) return null;
|
||||
return {
|
||||
ability,
|
||||
score: 15_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;
|
||||
// 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 && categories.has("resource"))
|
||||
&& !petSummon
|
||||
&& !offensiveSummon;
|
||||
if (actionableHealing && !damaging) {
|
||||
const healingTargetHealth = ability.target === "self" ? selfHealth : lowestHealth;
|
||||
if ((ability.target === "friendly" || ability.target === "any") && !lowest) return null;
|
||||
if (healingTargetHealth >= 0.9) return null;
|
||||
}
|
||||
// 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
|
||||
&& !petSummon && !offensiveSummon) return null;
|
||||
if (categories.has("movement") && !damaging && !context.moving) return null;
|
||||
if (context.requiredControl && categories.has("control")) {
|
||||
return { ability, score: 16_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null };
|
||||
}
|
||||
if (offensiveMajorCooldown && !durableTarget) 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;
|
||||
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null;
|
||||
if (isComboBuilder(ability) && 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 += 4_000 - profileIndex * 120;
|
||||
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")) {
|
||||
if (lowestHealth >= 0.8 && selfHealth >= 0.8) 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")) {
|
||||
if (!maintenanceBuff && selfHealth >= 0.55) return null;
|
||||
if (selfHealth < 0.55) score += selfHealth < 0.35 ? 8_000 : 3_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")) 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) };
|
||||
}
|
||||
|
||||
export function evaluateCombatRotation(context: RotationContext): RotationDecision | null {
|
||||
const rotationProfile = rotationProfileFor(
|
||||
context.classId,
|
||||
context.specializationId,
|
||||
context.specialization,
|
||||
);
|
||||
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(abilityHasRotationSemantics)
|
||||
.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)
|
||||
.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;
|
||||
}
|
||||
Reference in new issue
Block a user