import { learnedAbilityForBuild, resourceProfileForClass, type AbilityDefinition, } from "../abilityCatalog"; import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules"; import { PLAYER_AGGRO_ID } from "../aggro"; import { combatNow } from "../combatClock"; import { enemyControlPreventsActions } from "../enemyAttacks"; import type { CastFailureReason, CombatState } from "../combatStore"; import { equipmentAdjustedCombatAmount, resolvedAbilityCosts, useCombatStore } from "../combatStore"; import { isCoaBarbarianDecapitate } from "../coaBarbarianExecuteRuntime"; import { coaBarbarianDecapitateWeaponStrike, coaNormalizedMeleeWeaponSpeed } from "../coaBarbarianWeaponDamageRuntime"; import { coaGuardianFormationAbilityForCast } from "../coaGuardianFormationRuntime"; import { projectCoaGuardianParagonAbility } from "../coaGuardianParagonRuntime"; import { PLAYER_EQUIPMENT_OWNER_ID } from "../equipment"; import { resolvedEquipmentLoadout } from "../heirloomEquipment"; import { getMobPosition } from "../mobRuntimeRegistry"; import { getPartyRuntimePosition } from "../partyRuntimeRegistry"; import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingAbilityIsArea } from "../healingAbilityRules"; import type { PartyState } from "../partyStore"; import { usePartyStore } from "../partyStore"; import type { PartyRole } from "../partyRoles"; import type { BenchmarkScenarioDefinition } from "./benchmarkTypes"; import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "../romFiveRotation"; import { COMBAT_ROTATION_VERSION, abilityAuraMaintainedOnTarget, evaluateCombatRotation, nativeEarlyPriestHealReserveBlocksAbility, type RotationContext, } from "../combatRotation"; export const BENCHMARK_POLICY_ID = `rotation-v4:${COMBAT_ROTATION_VERSION}`; export interface BenchmarkPolicyDecision { readonly made: boolean; readonly cast: boolean; readonly resourceBlocked: boolean; readonly invalidFailures: number; } interface FriendlyTarget { readonly id: string; readonly health: number; readonly maxHealth: number; readonly missingPercent: number; } interface ScoredAbility { readonly ability: AbilityDefinition; readonly score: number; readonly friendlyTarget: FriendlyTarget | null; } function scoredAbilityAuraTargetId(candidate: ScoredAbility, combat: CombatState): string | null { const { ability, friendlyTarget } = candidate; if (ability.target === "friendly" || (ability.target === "any" && friendlyTarget)) { return friendlyTarget?.id ?? combat.selectedFriendlyActorId ?? PLAYER_AGGRO_ID; } if (ability.target === "hostile" || ability.target === "any") return combat.selectedTargetId; return PLAYER_AGGRO_ID; } const TRANSIENT_FAILURES = new Set([ "already-casting", "global-cooldown", "cooldown", "not-enough-resource", "not-enough-runes", "condition-not-met", "player-controlled", "school-locked", ]); function healthTarget(id: string, health: number, maxHealth: number): FriendlyTarget { return { id, health, maxHealth, missingPercent: maxHealth > 0 ? Math.max(0, 1 - health / maxHealth) : 0, }; } function friendlyTargets(combat: CombatState, party: PartyState): readonly FriendlyTarget[] { return [ healthTarget(PLAYER_AGGRO_ID, combat.health, combat.maxHealth), ...party.members.map((member) => healthTarget(member.id, member.health, member.maxHealth)), ] .filter((target) => target.health > 0) .sort((left, right) => right.missingPercent - left.missingPercent || left.id.localeCompare(right.id)); } function learnedAbility(ability: AbilityDefinition, combat: CombatState): AbilityDefinition | null { if (ability.passive) return null; const ranked = learnedAbilityForBuild(ability, combat.level, combat.talentRanks); if (!ranked) return null; if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null; if (combat.classId === "guardian" && combat.gearRole === "tank" && ranked.dbcSpellId === 803130) return null; if (isCoaBarbarianDecapitate(ranked)) { const mainHand = resolvedEquipmentLoadout(combat.equipment, combat.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: combat.classId, secondaryClassId: combat.secondaryClassId, specializationId: combat.specializationId, role: combat.gearRole, level: combat.level, })["main-hand"]; return coaBarbarianDecapitateWeaponStrike(ranked, combat.gearStats.meleeWeapon, mainHand ? coaNormalizedMeleeWeaponSpeed(mainHand) : null); } return projectCoaGuardianParagonAbility(coaGuardianFormationAbilityForCast(ranked), { id: PLAYER_AGGRO_ID, classId: combat.classId, talentRanks: combat.talentRanks, role: combat.gearRole, specializationId: combat.specializationId, health: combat.health, maxHealth: combat.maxHealth, }, combat.auras, combatNow()); } function isAreaAbility(ability: AbilityDefinition): boolean { const targeting = targetingRulesForAbility(ability); return targeting.shape !== "unit" || (targeting.maximumTargets ?? 1) > 1 || (targeting.radius ?? ability.radius ?? 0) > 0; } function throughputWeight(ability: AbilityDefinition): number { let weight = 0; for (const effect of ability.effects) { if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "shield" || effect.kind === "dot" || effect.kind === "hot") { weight += Math.max(0, effect.coefficient) * 10; weight += Math.max(0, effect.basePoints ?? 0) * 0.01; } else if (effect.kind === "finisher-damage") { weight += Math.max(0, effect.baseCoefficient + effect.perComboCoefficient * 3) * 10; } else if (effect.kind === "execute") { weight += Math.max(0, effect.coefficient) * 10; } } return Math.min(40, weight); } function scoreAbility( ability: AbilityDefinition, role: PartyRole, scenario: BenchmarkScenarioDefinition, combat: CombatState, targets: readonly FriendlyTarget[], ): ScoredAbility | null { const hints = aiHintsForAbility(ability); const categories = new Set(hints.categories); const injured = targets.filter((target) => target.missingPercent > 0.005); const lowest = targets[0] ?? null; const area = isAreaAbility(ability); let score = hints.priority ?? 0; let friendlyTarget: FriendlyTarget | null = null; if (role === "healer") { const canHeal = categories.has("heal") || categories.has("emergency-heal") || categories.has("area-heal"); const canShield = categories.has("mitigation") && ability.effects.some((effect) => effect.kind === "shield"); if ((!canHeal && !canShield) || !lowest || injured.length === 0) return null; friendlyTarget = !healingAbilityCanAidOthers(ability) ? targets.find((target) => target.id === PLAYER_AGGRO_ID) ?? lowest : lowest; if (friendlyTarget.missingPercent <= 0.005) return null; const pendingHot = healingAbilityHasPendingHot(ability, PLAYER_AGGRO_ID, friendlyTarget.id, combat.effects); const directHeal = ability.effects.some(effect => effect.kind === "heal"); if (pendingHot && !directHeal) return null; score += canHeal ? 100 : 70; score += friendlyTarget.missingPercent * 180; const healingAmount = ability.effects.reduce((total, effect) => total + (effect.kind === "heal" || effect.kind === "hot" ? (equipmentAdjustedCombatAmount(effect, combat.level, combat.gearStats, combat.classId, ability) + (effect.kind === "heal" ? friendlyTarget!.maxHealth * (effect.percentMaxHealth ?? 0) : 0)) * (effect.kind === "hot" ? Math.min(3, effect.ticks) : 1) : 0), 0); score += Math.min(70, healingAmount / Math.max(1, friendlyTarget.maxHealth) * 100); if (ability.effects.some(effect => effect.kind === "heal" && (effect.percentMaxHealth ?? 0) >= 0.5)) { score += friendlyTarget.missingPercent >= 0.4 ? 100 : -100; } const healthCost = resolvedAbilityCosts(ability, combat.talentModifiers, combat).find(cost => cost.resource === "health")?.amount ?? 0; if (healthCost > 0) { if (combat.health / combat.maxHealth < 0.5) return null; if (friendlyTarget.id === PLAYER_AGGRO_ID && healingAbilityCanAidOthers(ability)) return null; score -= healthCost / combat.maxHealth * 500; } if (pendingHot && friendlyTarget.missingPercent < 0.5) score -= 120; if (categories.has("emergency-heal")) score += friendlyTarget.missingPercent >= 0.5 ? 100 : -15; if (healingAbilityIsArea(ability)) score += injured.length >= 2 ? 35 * injured.length : -35; if (!directHeal && friendlyTarget.missingPercent >= 0.5) score -= 60; if (canShield) score += friendlyTarget.missingPercent >= 0.2 ? 45 : -20; if (canShield && (combat.actors[friendlyTarget.id]?.absorb ?? 0) > 0) return null; } else { const hostileDamage = categories.has("damage") || categories.has("execute") || categories.has("debuff"); if (role === "damage" && !hostileDamage) return null; if (role === "tank" && !hostileDamage && !categories.has("mitigation") && !categories.has("taunt")) return null; if (hostileDamage) score += 100 + throughputWeight(ability); if (categories.has("execute")) { const target = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null; const executeActive = Boolean(target && target.health / Math.max(1, target.maxHealth) <= 0.2); score += executeActive ? 120 : -180; } if (scenario.hostileTargets > 1) score += area ? 60 + scenario.hostileTargets * 4 : -15; else if (area) score -= 15; if (ability.effects.some((effect) => effect.kind === "dot") && combat.effects.some((effect) => effect.sourceAbilityId === ability.id && effect.targetId === combat.selectedTargetId)) { score -= 250; } if (role === "tank") { const playerHealth = combat.health / Math.max(1, combat.maxHealth); if (categories.has("mitigation")) score += playerHealth < 0.8 ? 150 : 20; if (categories.has("taunt")) { const looseMobs = Object.values(combat.mobs).filter((mob) => !mob.dead && mob.targetActorId !== PLAYER_AGGRO_ID).length; score += looseMobs > 0 ? 220 : -80; } } } return { ability, score, friendlyTarget }; } /** A deterministic, shared baseline policy. It is intentionally not an optimizer. */ export class BenchmarkController { #nextDecisionAt = 0; act(now: number, role: PartyRole, scenario: BenchmarkScenarioDefinition, moving: boolean): BenchmarkPolicyDecision { const combat = useCombatStore.getState(); const party = usePartyStore.getState(); if ( now < this.#nextDecisionAt || combat.health <= 0 || combat.activeCast || combat.globalCooldownEndsAt > now || ( combat.controlledUntil > now && enemyControlPreventsActions(combat.controlMechanic) ) ) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 }; const targets = friendlyTargets(combat, party); const learnedAbilities = combat.abilities .map((ability) => learnedAbility(ability, combat)) .filter((ability): ability is AbilityDefinition => ability !== null); const availableAbilities = learnedAbilities .filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability)) .filter((ability) => combat.statRuleset !== "rom" || ( (combat.cooldowns[ability.id] ?? 0) <= now && (ability.target !== "hostile" || (() => { const position = combat.selectedTargetId ? getMobPosition(combat.selectedTargetId) : null; if (!position) return false; const distance = Math.hypot(...position.map((value, index) => value - combat.playerPosition[index])); return distance >= ability.range.min && distance <= ability.range.max; })()) )); const candidates = availableAbilities .map((ability) => scoreAbility(ability, role, scenario, combat, targets)) .filter((candidate): candidate is ScoredAbility => candidate !== null) .sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id)); const hostileTarget = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null; const rotationContext: RotationContext = { now, canUseAbility: combat.statRuleset === "rom" ? (ability) => resolvedAbilityCosts(ability, combat.talentModifiers, combat).every((cost) => cost.amount <= (cost.resource === "health" ? combat.health : combat.resourcePools[cost.resource] ?? 0)) : undefined, manaCostForAbility: (ability) => resolvedAbilityCosts(ability, combat.talentModifiers, combat) .filter((cost) => cost.resource === "mana") .reduce((total, cost) => total + cost.amount, 0), classId: combat.classId, secondaryClassId: combat.secondaryClassId, statRuleset: combat.statRuleset, specializationId: combat.specializationId, role, level: combat.level, health: combat.health, maxHealth: combat.maxHealth, selfId: PLAYER_AGGRO_ID, resources: combat.resourcePools, maximumResources: combat.maxResourcePools, primaryResource: resourceProfileForClass(combat.classId).type, comboPoints: combat.comboPoints, comboTargetId: combat.comboTargetId, runes: combat.deathKnightRunes, abilities: availableAbilities, learnedAbilities, knownDamagingAbilityNames: new Set(availableAbilities.filter((ability) => { const categories = aiHintsForAbility(ability).categories; return categories.includes("damage") || categories.includes("execute"); }).map((ability) => ability.name)), hostileTarget, friendlyTargets: [ { id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 }, ...party.members.map((member) => ({ id: member.id, health: member.health, maxHealth: member.maxHealth, fallen: member.health <= 0, role: member.role, })), ], hostileCount: scenario.hostileTargets, targetIsLoose: role === "tank" && Boolean(hostileTarget?.targetActorId && hostileTarget.targetActorId !== PLAYER_AGGRO_ID), hasDangerousThreat: role !== "tank" && hostileTarget?.targetActorId === PLAYER_AGGRO_ID, moving, maintainedAbilityIds: new Set([ ...combat.effects.filter((effect) => effect.sourceActorId === PLAYER_AGGRO_ID && effect.remainingTicks > 0) .map((effect) => effect.sourceAbilityId), ]), maintainedAuras: combat.auras .filter((aura) => aura.expiresAt === null || aura.expiresAt > now) .map((aura) => ({ auraId: aura.definition.id, targetId: aura.targetId })), maintainedPeriodicEffects: combat.effects.map((effect) => ({ abilityId: effect.sourceAbilityId, sourceActorId: effect.sourceActorId, targetId: effect.targetId ?? PLAYER_AGGRO_ID, remainingMs: Math.max(0, effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs - now), })), activeAuraExclusiveGroups: new Set(combat.auras .filter((aura) => aura.sourceId === PLAYER_AGGRO_ID && (aura.expiresAt === null || aura.expiresAt > now)) .flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])), activeSummonAbilityIds: new Set(combat.summons .filter((summon) => summon.ownerActorId === PLAYER_AGGRO_ID && summon.expiresAt > now) .map((summon) => summon.sourceAbilityId)), hasActivePet: combat.summons.some((summon) => { if (summon.ownerActorId !== PLAYER_AGGRO_ID || summon.expiresAt <= now) return false; const source = availableAbilities.find((ability) => ability.id === summon.sourceAbilityId); return source?.effects.some((effect) => effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false; }), }; const sharedDecision = evaluateCombatRotation(rotationContext); for (let index = candidates.length - 1; index >= 0; index -= 1) { const candidate = candidates[index]; const ability = candidate.ability; if (nativeEarlyPriestHealReserveBlocksAbility(ability, rotationContext) || (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext)) || abilityAuraMaintainedOnTarget(ability, rotationContext, scoredAbilityAuraTargetId(candidate, combat))) { candidates.splice(index, 1); } } if (sharedDecision) { const friendlyTarget = sharedDecision.targetId ? targets.find((target) => target.id === sharedDecision.targetId) ?? null : null; const existing = candidates.findIndex((candidate) => candidate.ability.id === sharedDecision.ability.id); if (existing >= 0) candidates.splice(existing, 1); candidates.unshift({ ability: sharedDecision.ability, score: Number.POSITIVE_INFINITY, friendlyTarget }); } // Rage healers must begin weapon attacks to fund their heals before allies are injured. if (role === "healer" && combat.selectedTargetId && combat.abilities.some(ability => ability.effects.some(effect => effect.kind === "heal") && ability.cost.resource === "rage")) { const starter = combat.abilities.map(ability => learnedAbility(ability, combat)).find(ability => ability && ability.target === "hostile" && ability.range.max <= 5 && ability.effects.some(effect => effect.kind === "damage") && resolvedAbilityCosts(ability, combat.talentModifiers, combat).every(cost => cost.amount === 0)); if (starter && !combat.autoAttackTargetId) candidates.push({ ability: starter, score: 0, friendlyTarget: null }); if (combat.resource < 35) { const generators = combat.abilities.map(ability => learnedAbility(ability, combat)).filter(ability => ability && ability.target === "hostile" && ability.effects.some(effect => (effect.kind === "resource" && (!effect.resource || effect.resource === "rage") && effect.amount > 0) || ((effect.kind === "damage" || effect.kind === "dot") && effect.onDamageResource?.resource === "rage" && effect.onDamageResource.amount > 0)) && resolvedAbilityCosts(ability, combat.talentModifiers, combat).every(cost => cost.resource !== "rage" && (cost.resource !== "health" || (combat.health / combat.maxHealth > 0.65 && cost.amount / combat.maxHealth <= 0.1)))); for (const generator of generators) { if (!generator) continue; const needsRageHeal = targets.some(target => target.id !== PLAYER_AGGRO_ID && target.missingPercent > 0.1); candidates.push({ ability: generator, score: needsRageHeal && combat.resource < 20 ? 400 : -10, friendlyTarget: null }); candidates.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id)); } } } let resourceBlocked = false; let invalidFailures = 0; for (const candidate of candidates) { const { ability, friendlyTarget } = candidate; let targetActorId: string | null | undefined; if (ability.target === "friendly" || (ability.target === "any" && friendlyTarget)) { if (friendlyTarget?.id === PLAYER_AGGRO_ID) { party.selectSelf(); targetActorId = null; } else if (friendlyTarget) { party.selectMember(friendlyTarget.id); targetActorId = friendlyTarget.id; } } else if (ability.target === "hostile" || ability.target === "any") { party.clearSelection(); useCombatStore.getState().selectFriendlyActor(null); targetActorId = combat.selectedTargetId; } const hostilePosition = combat.selectedTargetId ? getMobPosition(combat.selectedTargetId) : null; const targeting = targetingRulesForAbility(ability); const result = useCombatStore.getState().castAbility(ability.id, { now, origin: combat.playerPosition, targetActorId, ...(targeting.shape === "ground" ? { groundPosition: ability.target === "hostile" || ability.target === "any" ? hostilePosition ?? combat.playerPosition : ability.target === "self" || friendlyTarget?.id === PLAYER_AGGRO_ID ? combat.playerPosition : getPartyRuntimePosition(friendlyTarget?.id ?? "") ?? combat.playerPosition } : {}), }); if (result.ok) { this.#nextDecisionAt = now + 50; return { made: true, cast: true, resourceBlocked: false, invalidFailures }; } if (result.reason === "not-enough-resource" || result.reason === "not-enough-runes") resourceBlocked = true; if (result.reason && !TRANSIENT_FAILURES.has(result.reason)) invalidFailures += 1; } this.#nextDecisionAt = now + 1_500; return { made: true, cast: false, resourceBlocked, invalidFailures }; } }