Files
healer-man/src/game/benchmark/benchmarkController.ts
T

410 lines
21 KiB
TypeScript

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<CastFailureReason>([
"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 };
}
}