Files
healer-man/src/game/partyAbilityAi.ts
T
2026-08-20 14:05:50 -04:00

151 lines
6.7 KiB
TypeScript

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