Files
healer-man/src/game/partyAbilityAi.ts
T
2026-09-17 09:13:15 -04:00

183 lines
7.2 KiB
TypeScript

import {
abilitiesForCharacter,
learnedAbilityForBuild,
type AbilityDefinition,
type AbilityEffect,
type ResourceType,
} from "./abilityCatalog";
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
import {
evaluateCombatRotation,
type RotationDecision,
type RotationFriendlyTarget,
} from "./combatRotation";
import { spendDeathKnightRunes } from "./combatResources";
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
import type { PartyMember } from "./partyStore";
import { baseResourcesFor } from "./wotlkStats";
import { companionResourceMaximum } from "./combatActorAuthority";
export interface PartyAbilityContext {
readonly now: number;
readonly target: MobCombatState | null;
readonly targetIsLoose?: boolean;
readonly hasDangerousThreat?: boolean;
readonly requiredControl?: boolean;
readonly allyNeedsDispel?: boolean;
readonly fallenAlly?: boolean;
readonly injuredAllies?: number;
readonly lowestHealthFraction?: number;
readonly hostileCount?: number;
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
readonly maintainedAbilityIds?: ReadonlySet<string>;
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
readonly activeSummonAbilityIds?: ReadonlySet<string>;
readonly hasActivePet?: boolean;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
}
function currentResource(member: PartyMember, type: ResourceType): number {
return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0);
}
function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly { resource: ResourceType; amount: number }[] {
return (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ({
resource: cost.resource,
amount: cost.percentage ? cost.amount / 100 * (cost.percentageBasis === "base-mana"
? baseResourcesFor(member.classId, member.level).mana
: cost.resource === "health" ? member.maxHealth : companionResourceMaximum(member, cost.resource)) : cost.amount,
}));
}
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false;
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
if (abilityCosts(member, ability).some(cost => cost.resource === "health"
? member.health <= cost.amount : currentResource(member, cost.resource) < cost.amount)) return false;
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null;
}
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
return abilitiesForCharacter(member.classId, member.secondaryClassId)
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => partyAbilityIsReady(member, ability, now));
}
export function choosePartyRotationDecision(
member: PartyMember,
context: PartyAbilityContext,
): RotationDecision | null {
const friendlyTargets = context.friendlyTargets ?? (context.fallenAlly || context.allyNeedsDispel
|| (context.lowestHealthFraction ?? 1) < 1
? [{
id: "rotation-friendly-target",
health: (context.lowestHealthFraction ?? 1) * 100,
maxHealth: 100,
fallen: context.fallenAlly,
needsDispel: context.allyNeedsDispel,
}]
: []);
const maximumResources = Object.fromEntries(
[...new Set([...Object.keys(member.resourcePools), member.resourceType])]
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
);
return evaluateCombatRotation({
now: context.now,
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
specialization: member.specialization,
role: member.role,
level: member.level,
health: member.health,
maxHealth: member.maxHealth,
selfId: member.id,
resources: member.resourcePools,
maximumResources,
primaryResource: member.resourceType,
comboPoints: member.comboPoints,
comboTargetId: member.comboTargetId,
runes: member.runes,
abilities: candidateAbilities(member, context.now),
hostileTarget: context.target,
friendlyTargets,
hostileCount: context.hostileCount,
targetIsLoose: context.targetIsLoose,
hasDangerousThreat: context.hasDangerousThreat,
requiredControl: context.requiredControl,
maintainedAbilityIds: context.maintainedAbilityIds,
activeAuraExclusiveGroups: context.activeAuraExclusiveGroups,
activeSummonAbilityIds: context.activeSummonAbilityIds,
hasActivePet: context.hasActivePet,
canUseAbility: context.canUseAbility,
});
}
export function choosePartyAbility(
member: PartyMember,
context: PartyAbilityContext,
): AbilityDefinition | null {
return choosePartyRotationDecision(member, context)?.ability ?? null;
}
export function commitPartyAbilityUse(
member: PartyMember,
ability: AbilityDefinition,
now: number,
): PartyMember | null {
if (!partyAbilityIsReady(member, ability, now)) return null;
const resourcePools = { ...member.resourcePools };
const lastResourceSpentAt = { ...member.lastResourceSpentAt };
let resource = member.resource;
let health = member.health;
for (const cost of abilityCosts(member, ability)) {
const amount = cost.amount;
if (cost.resource === "health") { health -= amount; continue; }
const current = currentResource(member, cost.resource);
resourcePools[cost.resource] = Math.max(0, current - amount);
if (amount > 0) lastResourceSpentAt[cost.resource] = now;
if (cost.resource === member.resourceType) resource = resourcePools[cost.resource] ?? resource;
}
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
const runes = runeCost ? spendDeathKnightRunes(member.runes, runeCost, now) : member.runes;
if (!runes) return null;
return {
...member,
health,
resource,
resourcePools,
lastResourceSpentAt,
runes,
cooldowns: ability.cooldownMs > 0
? { ...member.cooldowns, [ability.id]: now + ability.cooldownMs }
: member.cooldowns,
globalCooldownEndsAt: now + ability.gcdMs,
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
? now + Math.max(8_000, ability.cooldownMs)
: member.tauntReadyAt,
};
}
export function partyAbilityAmount(
member: PartyMember,
ability: AbilityDefinition,
effect: AbilityEffect,
): number {
const coefficient = "coefficient" in effect
? effect.coefficient
: effect.kind === "finisher-damage"
? effect.baseCoefficient + effect.perComboCoefficient * member.comboPoints
: 0;
return equipmentAdjustedCombatAmount(
"coefficient" in effect ? effect : coefficient,
member.level,
member.gearStats,
member.classId,
ability,
);
}