151 lines
6.7 KiB
TypeScript
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)));
|
|
}
|