import { abilitiesForClass, abilityAtLevel, isAbilityUnlocked, type AbilityDefinition, type AbilityEffect, type ResourceType, } from "./abilityCatalog"; import { aiHintsForAbility, runeCostForAbility } from "./abilityRules"; import { spendDeathKnightRunes } from "./combatResources"; import type { MobCombatState } from "./combatStore"; import type { PartyMember } from "./partyStore"; 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; } function currentResource(member: PartyMember, type: ResourceType): number { return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0); } function abilityCosts(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.amount, })); } export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean { if (ability.passive || !isAbilityUnlocked(ability, member.level)) 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(ability).some((cost) => { const amount = cost.amount <= 1 && ability.cost.percentage ? (cost.resource === member.resourceType ? member.maxResource : 100) * cost.amount : cost.amount; return currentResource(member, cost.resource) < 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) .filter((ability) => partyAbilityIsReady(member, abilityAtLevel(ability, member.level), now)) .map((ability) => abilityAtLevel(ability, member.level)); } function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number { const categories = new Set(aiHintsForAbility(ability).categories); 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")) score += 350; 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) .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; for (const cost of abilityCosts(ability)) { const maximum = cost.resource === member.resourceType ? member.maxResource : 100; const amount = cost.amount <= 1 && ability.cost.percentage ? maximum * cost.amount : cost.amount; 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, 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; const power = member.level * 4 + member.gearStats.spellPower + member.gearStats.attackPower * 0.35 + member.gearStats.rangedAttackPower * 0.35; return Math.max(1, Math.round(Math.max(0.1, coefficient) * Math.max(8, power))); }