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

168 lines
8.1 KiB
TypeScript

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,
);
}