import { abilitiesForClass, abilityAtLevel, isAbilityUnlocked, type AbilityDefinition, type AbilityEffect, type ResourceType, } from "./abilityCatalog"; import { aiHintsForAbility, runeCostForAbility } from "./abilityRules"; 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 requiredControl?: boolean; readonly allyNeedsDispel?: boolean; readonly fallenAlly?: boolean; readonly injuredAllies?: number; readonly lowestHealthFraction?: number; 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 || !isAbilityUnlocked(ability, member.level)) return false; if (ability.talentEntryId !== undefined && !(member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0)) 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 abilitiesForClass(member.classId) .map((ability) => abilityAtLevel(ability, member.level, ability.talentEntryId === undefined ? undefined : member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0)) .filter((ability) => partyAbilityIsReady(member, ability, now)); } function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number { const categories = new Set(aiHintsForAbility(ability).categories); const damaging = categories.has("damage") || categories.has("execute"); if (categories.has("taunt") && (member.role !== "tank" || !context.targetIsLoose)) return 0; if (!damaging && categories.has("interrupt") && !context.target?.activeCast) return 0; const control = categories.has("control") || ability.effects.some((effect) => effect.kind === "apply-aura" && effect.control); if (!damaging && control && !context.requiredControl) return 0; let score = ability.ai?.priority ?? 0; if (member.role === "tank") { if (context.target?.activeCast && categories.has("interrupt")) score += 1_000; if (context.targetIsLoose && categories.has("taunt")) score += 900; if (categories.has("mitigation") && member.health / member.maxHealth < 0.55) score += 760; if (categories.has("control") && context.requiredControl) score += 700; if (categories.has("debuff")) score += 300; if (categories.has("damage")) score += 180; } else if (member.role === "healer") { if (context.fallenAlly && categories.has("resurrection")) score += 1_200; if (context.allyNeedsDispel && categories.has("dispel")) score += 1_050; if ((context.lowestHealthFraction ?? 1) < 0.3 && categories.has("emergency-heal")) score += 950; if ((context.injuredAllies ?? 0) >= 3 && categories.has("area-heal")) score += 850; if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) score += 600; if (categories.has("mitigation") && ((context.lowestHealthFraction ?? 1) < 0.8 || member.health / member.maxHealth < 0.8)) score += 350; if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) { score += Math.min(150, ability.effects.reduce((total, effect) => total + (effect.kind === "heal" || effect.kind === "hot" ? partyAbilityAmount(member, ability, effect) * (effect.kind === "hot" ? Math.min(3, effect.ticks) : 1) : 0), 0) / member.maxHealth * 100); } if (context.target?.activeCast && categories.has("interrupt")) score += 250; if (categories.has("damage")) score += 80; } else { if (context.target?.activeCast && categories.has("interrupt")) score += 1_000; if (context.requiredControl && categories.has("control")) score += 900; if ((context.target?.health ?? 1) / Math.max(1, context.target?.maxHealth ?? 1) <= 0.2 && categories.has("execute")) score += 800; if (categories.has("debuff")) score += 520; if (categories.has("damage")) score += 300; } if (ability.castTimeMs > 0 && context.target?.activeCast && categories.has("interrupt")) score -= 800; return score; } export function choosePartyAbility( member: PartyMember, context: PartyAbilityContext, ): AbilityDefinition | null { return candidateAbilities(member, context.now) .filter((ability) => context.canUseAbility?.(ability) ?? true) .map((ability) => ({ ability, score: scoreAbility(member, ability, context) })) .filter((candidate) => candidate.score > 0) .sort((left, right) => right.score - left.score || left.ability.cooldownMs - right.ability.cooldownMs || left.ability.id.localeCompare(right.ability.id))[0]?.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 * 5 : 0; return equipmentAdjustedCombatAmount( "coefficient" in effect ? effect : coefficient, member.level, member.gearStats, member.classId, ability, ); }