183 lines
7.2 KiB
TypeScript
183 lines
7.2 KiB
TypeScript
import {
|
|
abilitiesForCharacter,
|
|
learnedAbilityForBuild,
|
|
type AbilityDefinition,
|
|
type AbilityEffect,
|
|
type ResourceType,
|
|
} from "./abilityCatalog";
|
|
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
|
import {
|
|
evaluateCombatRotation,
|
|
type RotationDecision,
|
|
type RotationFriendlyTarget,
|
|
} from "./combatRotation";
|
|
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 hasDangerousThreat?: boolean;
|
|
readonly requiredControl?: boolean;
|
|
readonly allyNeedsDispel?: boolean;
|
|
readonly fallenAlly?: boolean;
|
|
readonly injuredAllies?: number;
|
|
readonly lowestHealthFraction?: number;
|
|
readonly hostileCount?: number;
|
|
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
|
|
readonly maintainedAbilityIds?: ReadonlySet<string>;
|
|
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
|
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
|
readonly hasActivePet?: boolean;
|
|
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 || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) 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 abilitiesForCharacter(member.classId, member.secondaryClassId)
|
|
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
|
.filter((ability): ability is AbilityDefinition => ability !== null)
|
|
.filter((ability) => partyAbilityIsReady(member, ability, now));
|
|
}
|
|
|
|
export function choosePartyRotationDecision(
|
|
member: PartyMember,
|
|
context: PartyAbilityContext,
|
|
): RotationDecision | null {
|
|
const friendlyTargets = context.friendlyTargets ?? (context.fallenAlly || context.allyNeedsDispel
|
|
|| (context.lowestHealthFraction ?? 1) < 1
|
|
? [{
|
|
id: "rotation-friendly-target",
|
|
health: (context.lowestHealthFraction ?? 1) * 100,
|
|
maxHealth: 100,
|
|
fallen: context.fallenAlly,
|
|
needsDispel: context.allyNeedsDispel,
|
|
}]
|
|
: []);
|
|
const maximumResources = Object.fromEntries(
|
|
[...new Set([...Object.keys(member.resourcePools), member.resourceType])]
|
|
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
|
|
);
|
|
return evaluateCombatRotation({
|
|
now: context.now,
|
|
classId: member.classId,
|
|
secondaryClassId: member.secondaryClassId,
|
|
specializationId: member.specializationId,
|
|
specialization: member.specialization,
|
|
role: member.role,
|
|
level: member.level,
|
|
health: member.health,
|
|
maxHealth: member.maxHealth,
|
|
selfId: member.id,
|
|
resources: member.resourcePools,
|
|
maximumResources,
|
|
primaryResource: member.resourceType,
|
|
comboPoints: member.comboPoints,
|
|
comboTargetId: member.comboTargetId,
|
|
runes: member.runes,
|
|
abilities: candidateAbilities(member, context.now),
|
|
hostileTarget: context.target,
|
|
friendlyTargets,
|
|
hostileCount: context.hostileCount,
|
|
targetIsLoose: context.targetIsLoose,
|
|
hasDangerousThreat: context.hasDangerousThreat,
|
|
requiredControl: context.requiredControl,
|
|
maintainedAbilityIds: context.maintainedAbilityIds,
|
|
activeAuraExclusiveGroups: context.activeAuraExclusiveGroups,
|
|
activeSummonAbilityIds: context.activeSummonAbilityIds,
|
|
hasActivePet: context.hasActivePet,
|
|
canUseAbility: context.canUseAbility,
|
|
});
|
|
}
|
|
|
|
export function choosePartyAbility(
|
|
member: PartyMember,
|
|
context: PartyAbilityContext,
|
|
): AbilityDefinition | null {
|
|
return choosePartyRotationDecision(member, context)?.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 * member.comboPoints
|
|
: 0;
|
|
return equipmentAdjustedCombatAmount(
|
|
"coefficient" in effect ? effect : coefficient,
|
|
member.level,
|
|
member.gearStats,
|
|
member.classId,
|
|
ability,
|
|
);
|
|
}
|