401 lines
20 KiB
TypeScript
401 lines
20 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,
|
|
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;
|
|
}
|
|
|
|
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),
|
|
...combat.auras.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID
|
|
&& (aura.expiresAt === null || aura.expiresAt > now))
|
|
.flatMap((aura) => availableAbilities
|
|
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.id === aura.definition.id))
|
|
.map((ability) => ability.id)),
|
|
]),
|
|
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 ability = candidates[index].ability;
|
|
if (nativeEarlyPriestHealReserveBlocksAbility(ability, rotationContext)
|
|
|| (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext))) {
|
|
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 };
|
|
}
|
|
}
|