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; readonly activeAuraExclusiveGroups?: ReadonlySet; readonly activeSummonAbilityIds?: ReadonlySet; 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, ); }