Files
healer-man/src/game/partyAbilityAi.ts
T
2026-10-06 22:25:34 -04:00

691 lines
30 KiB
TypeScript

import type { RomNativeDerivedStats } from "./romNativeStatsTypes";
import type { RomNativeDamageTargetContext } from "./romNativeCombatAmount";
import type { EnemyDisarmWeaponChannel } from "./enemyWeaponDebuffs";
import {
abilitiesForCharacter,
learnedAbilityForBuild,
type AbilityDefinition,
type AbilityEffect,
type ResourceType,
} from "./abilityCatalog";
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
import {
evaluateCombatRotation,
rotationProfileFor,
type RotationDecision,
type RotationFriendlyTarget,
} from "./combatRotation";
import { spendDeathKnightRunes } from "./combatResources";
import { resolveResourceValue } from "./combatAuras";
import { combatHasLineOfSight } from "./combatSpatial";
import { equipmentAdjustedCombatAmount, useCombatStore, type MobCombatState } from "./combatStore";
import { getMobPosition } from "./mobRuntimeRegistry";
import type { PartyMember } from "./partyStore";
import { baseResourcesFor, deriveCharacterStats } from "./wotlkStats";
import { companionResourceMaximum } from "./combatActorAuthority";
import { currentCompanionCooldownPolicy, type CompanionCooldownMechanicRule } from "./companionCooldownPolicy";
import { WOW335_MAGE_ARCANE_CONCENTRATION_TAG } from "./wow335MageTalentRuntime";
import {
WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID,
wow335MageImprovedCounterspellCooldownReductionMs,
} from "./wow335MageImprovedCounterspellRuntime";
import {
WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID,
wow335MageImprovedFireBlastCooldownReductionMs,
wow335MageImprovedFireBlastDamage,
} from "./wow335MageImprovedFireBlastRuntime";
import { WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG } from "./wow335HunterRuntime";
import {
romPriestWarriorStanceRequired,
romPriestWarriorStanceUseAllowed,
} from "./romPriestKnightOverrides";
import {
romKnightCompassionWhirlwindShieldCooldownReductionMs,
romKnightWarriorWhirlwindAdjustment,
} from "./romKnightOverrides";
import { romWarriorDruidWhirlwindAdjustment } from "./romWarriorDruidRageOfCalmness";
import {
ROM_ROGUE_COERCE_PASSIVE_SPELL_ID,
romRogueCoerceAbilityAdjustment,
} from "./romRogueMechanics";
import { romPriestChampionCooldownOverride } from "./romPriestChampionOverrides";
import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers";
import { romWarlockMageSoulBrandStingHasWillpowerBladeState } from "./romWarlockMageSoulBrandSting";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { romBloodArrowCastCosts } from "./romBloodArrow";
import { coaWitchDoctorBrewingAbilityForCast, coaWitchDoctorIngredientIdForAbility } from "./coaWitchDoctorBrewingRuntime";
import { coaBarbarianDecapitateWeaponStrike, coaNormalizedMeleeWeaponSpeed } from "./coaBarbarianWeaponDamageRuntime";
import { coaGuardianFormationAbilityForCast } from "./coaGuardianFormationRuntime";
import { projectCoaGuardianParagonAbility } from "./coaGuardianParagonRuntime";
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
export interface PartyAbilityContext {
readonly now: number;
readonly target: MobCombatState | null;
readonly targetIsLoose?: boolean;
readonly hasDangerousThreat?: boolean;
readonly enemyUsesMeleeAttacks?: boolean;
readonly incomingMeleeDanger?: boolean;
readonly needsEscape?: boolean;
readonly moving?: 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 partyResourceDeficit?: (ability: AbilityDefinition) => boolean;
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
readonly incomingDefensiveMechanic?: CompanionCooldownMechanicRule | null;
/** Inner Focus-style setup lets the next direct healing spell ignore resource costs. */
readonly nextHealingSpellFree?: boolean;
}
export interface PartyResourcePoolSnapshot {
readonly resources: Readonly<Partial<Record<ResourceType, { readonly current: number; readonly maximum: number }>>>;
}
export function partyAbilityHasDepletedPartyResource(
ability: AbilityDefinition,
recipients: readonly PartyResourcePoolSnapshot[],
): boolean {
return ability.effects.some((effect) => {
if (effect.kind !== "resource" || effect.scope !== "party" || !effect.resource) return false;
return recipients.some(({ resources }) => {
const pool = resources[effect.resource!];
return Boolean(pool && pool.current < pool.maximum);
});
});
}
function currentResource(member: PartyMember, type: ResourceType): number {
return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0);
}
function partyRomPriestChampionCooldownOverride(
member: PartyMember,
ability: AbilityDefinition,
): number | null {
if (member.classId !== "rom-priest" || member.secondaryClassId !== "rom-champion") return null;
const infusedLightChain = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => (
candidate.source === "runewaker"
&& candidate.classId === "rom-priest"
&& candidate.secondaryClassId === "rom-champion"
&& candidate.passive
&& candidate.dbcSpellId === 498610
));
if (!infusedLightChain
|| !learnedAbilityForBuild(infusedLightChain, member.level, member.talentRanks)) return null;
return romPriestChampionCooldownOverride(ability, [infusedLightChain.dbcSpellId]);
}
function partyAbilityWithRomPriestChampionCooldown(
member: PartyMember,
ability: AbilityDefinition,
): AbilityDefinition {
const cooldownOverride = partyRomPriestChampionCooldownOverride(member, ability);
return cooldownOverride === null ? ability : { ...ability, cooldownMs: cooldownOverride };
}
export function partyHunterLockAndLoadReady(
member: PartyMember,
ability: AbilityDefinition,
now?: number,
): boolean {
if (member.classId !== "hunter"
|| !["Arcane Shot", "Explosive Shot"].includes(ability.name)
|| Math.max(0, Math.trunc(member.talentRanks["talent-1306"] ?? 0)) <= 0) return false;
return useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
&& aura.stacks > 0
&& aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG)
&& (now === undefined || aura.expiresAt === null || aura.expiresAt > now));
}
export function partyAbilityWithHunterLockAndLoad(
member: PartyMember,
ability: AbilityDefinition,
now: number,
): AbilityDefinition {
if (!partyHunterLockAndLoadReady(member, ability, now)) return ability;
const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => (
cost.resource === "mana" ? { ...cost, amount: 0 } : cost
));
return { ...ability, cooldownMs: 0, cost: costs[0] ?? ability.cost, costs };
}
function partyKnightWarriorWhirlwindAdjustment(
member: PartyMember,
ability: AbilityDefinition,
): ReturnType<typeof romKnightWarriorWhirlwindAdjustment> {
if (member.classId !== "rom-knight" || member.secondaryClassId !== "rom-warrior") {
return { rageCostReduction: 0, cooldownReductionMs: 0 };
}
const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((candidate) => candidate.source === "runewaker"
&& candidate.classId === "rom-knight"
&& candidate.secondaryClassId === "rom-warrior"
&& candidate.dbcSpellId === 491639
&& candidate.passive
&& learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null)
.map((candidate) => candidate.dbcSpellId);
return romKnightWarriorWhirlwindAdjustment(
member.classId,
member.secondaryClassId,
ability,
learnedPassiveSpellIds,
);
}
function partyWarriorDruidWhirlwindAdjustment(
member: PartyMember,
ability: AbilityDefinition,
): ReturnType<typeof romWarriorDruidWhirlwindAdjustment> {
if (member.classId !== "rom-warrior" || member.secondaryClassId !== "rom-druid") {
return { rageCostReduction: 0, cooldownReductionMs: 0 };
}
const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((candidate) => candidate.source === "runewaker"
&& candidate.classId === "rom-warrior"
&& candidate.secondaryClassId === "rom-druid"
&& candidate.dbcSpellId === 494377
&& candidate.passive
&& learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null)
.map((candidate) => candidate.dbcSpellId);
return romWarriorDruidWhirlwindAdjustment({
classId: member.classId,
secondaryClassId: member.secondaryClassId,
learnedPassiveSpellIds,
ability,
});
}
function partyKnightCompassionCooldownReductionMs(
member: PartyMember,
ability: AbilityDefinition,
): number {
if (member.classId !== "rom-knight" || member.secondaryClassId !== "rom-priest") return 0;
const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((candidate) => candidate.source === "runewaker"
&& candidate.classId === "rom-knight"
&& candidate.secondaryClassId === "rom-priest"
&& candidate.dbcSpellId === 491635
&& candidate.passive
&& learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null)
.map((candidate) => candidate.dbcSpellId);
return romKnightCompassionWhirlwindShieldCooldownReductionMs(
member.classId,
member.secondaryClassId,
ability,
learnedPassiveSpellIds,
);
}
function partyAbilityWithKnightWarriorWhirlwind(
member: PartyMember,
ability: AbilityDefinition,
): AbilityDefinition {
const knightAdjustment = partyKnightWarriorWhirlwindAdjustment(member, ability);
const warriorDruidAdjustment = partyWarriorDruidWhirlwindAdjustment(member, ability);
const rageCostReduction = knightAdjustment.rageCostReduction + warriorDruidAdjustment.rageCostReduction;
const cooldownReductionMs = knightAdjustment.cooldownReductionMs + warriorDruidAdjustment.cooldownReductionMs;
if (rageCostReduction <= 0 && cooldownReductionMs <= 0) return ability;
const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => (
cost.resource === "rage" && !cost.percentage
? { ...cost, amount: Math.max(0, cost.amount - rageCostReduction) }
: cost
));
return {
...ability,
cooldownMs: Math.max(0, ability.cooldownMs - cooldownReductionMs),
cost: costs[0] ?? ability.cost,
costs,
};
}
function partyRogueCoerceEnergyCostReduction(member: PartyMember, ability: AbilityDefinition): number {
if (member.classId !== "rom-rogue" || member.secondaryClassId !== "rom-scout") return 0;
const passiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((candidate) => candidate.source === "runewaker"
&& candidate.classId === "rom-rogue"
&& candidate.secondaryClassId === "rom-scout"
&& candidate.passive
&& candidate.dbcSpellId === ROM_ROGUE_COERCE_PASSIVE_SPELL_ID
&& learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null)
.map((candidate) => candidate.dbcSpellId);
return romRogueCoerceAbilityAdjustment(
member.classId,
member.secondaryClassId,
ability.source,
ability.dbcSpellId,
passiveSpellIds,
).energyCostReduction;
}
function partyAbilityWithRogueCoerce(member: PartyMember, ability: AbilityDefinition): AbilityDefinition {
const reduction = partyRogueCoerceEnergyCostReduction(member, ability);
if (reduction <= 0) return ability;
const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => (
cost.resource === "energy" && !cost.percentage
? { ...cost, amount: Math.max(0, cost.amount - reduction) }
: cost
));
return { ...ability, cost: costs[0] ?? ability.cost, costs };
}
export function partyAbilityCosts(member: PartyMember, ability: AbilityDefinition, now?: number): readonly { resource: ResourceType; amount: number }[] {
const adjustedAbility = partyAbilityWithRogueCoerce(
member,
partyAbilityWithKnightWarriorWhirlwind(member, ability),
);
const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id
&& (now === undefined || aura.expiresAt === null || aura.expiresAt > now));
const mageClearcasting = member.classId === "mage" && auras.some((aura) => (
aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG)
));
const resolved = (adjustedAbility.costs?.length ? adjustedAbility.costs : [adjustedAbility.cost]).map((cost) => {
const baseMana = cost.percentageBasis === "base-mana"
? adjustedAbility.source === "runewaker" && member.statRuleset === "rom"
? deriveCharacterStats(
member.classId, member.level, EMPTY_ITEM_STATS, member.raceId, {}, member.secondaryClassId,
member.gender, "rom",
).mana
: baseResourcesFor(member.classId, member.level).mana
: 0;
const maximum = cost.percentageBasis === "base-mana"
? baseMana
: cost.resource === "health" ? member.maxHealth : companionResourceMaximum(member, cost.resource);
const baseAmount = cost.percentage ? cost.amount / 100 * maximum : cost.amount;
const adjusted = resolveResourceValue(
resolveResourceValue(baseAmount, auras, cost.resource, "cost"),
auras,
"power",
"cost",
);
return {
resource: cost.resource,
amount: cost.resource === "mana" && partyHunterLockAndLoadReady(member, ability, now)
? 0
: cost.resource !== "health" && mageClearcasting ? 0 : Math.max(0, Math.round(adjusted)),
};
});
const bloodArrowCosts = romBloodArrowCastCosts(
ability,
member,
auras,
member.id,
member.maxHealth,
companionResourceMaximum(member, "mana"),
now,
);
return [...resolved, ...bloodArrowCosts];
}
export function partyAbilityIsReady(
member: PartyMember,
ability: AbilityDefinition,
now: number,
ignoreResourceCosts = false,
): boolean {
if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false;
if (member.classId === "rom-warlock" && member.secondaryClassId === "rom-mage"
&& ability.source === "runewaker" && ability.dbcSpellId === 498710
&& !romWarlockMageSoulBrandStingHasWillpowerBladeState(useCombatStore.getState().auras.filter((aura) => (
aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)
)))) return false;
if (romPriestWarriorStanceRequired(ability)) {
const activeAuraTags = useCombatStore.getState().auras
.filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now))
.flatMap((aura) => aura.definition.utilityTags ?? []);
if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false;
}
const hasRomPriestChampionCooldownOverride = partyRomPriestChampionCooldownOverride(member, ability) !== null;
if ((ability.gcdMs > 0 && member.globalCooldownEndsAt > now)
|| (!partyHunterLockAndLoadReady(member, ability, now)
&& !hasRomPriestChampionCooldownOverride
&& (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 (!ignoreResourceCosts && partyAbilityCosts(member, ability, now).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 learnedAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
const combat = useCombatStore.getState();
const mainHand = member.classId === "barbarian" ? resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
ownerId: member.id, ownerKind: "party", classId: member.classId,
secondaryClassId: member.secondaryClassId, specializationId: member.specializationId,
role: member.role, level: member.level,
})["main-hand"] : null;
return abilitiesForCharacter(member.classId, member.secondaryClassId)
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null)
.map((ability) => partyAbilityWithRomPriestChampionCooldown(member, ability))
.map((ability) => coaWitchDoctorBrewingAbilityForCast(ability, combat.auras, member.id, now))
.map(coaGuardianFormationAbilityForCast)
.map((ability) => projectCoaGuardianParagonAbility(ability, member, combat.auras, now))
.map((ability) => coaBarbarianDecapitateWeaponStrike(ability, member.gearStats.meleeWeapon,
mainHand ? coaNormalizedMeleeWeaponSpeed(mainHand) : null));
}
function isComboFinisher(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "finisher-damage"
|| (effect.kind === "combo" && effect.amount < 0));
}
const RESERVED_SIGNATURE_ACTIONS = new Set(["Mortal Strike", "Mutilate", "Envenom"]);
function shouldReserveForHigherPriority(
member: PartyMember,
learned: readonly AbilityDefinition[],
decision: RotationDecision,
now: number,
): boolean {
const rotation = rotationProfileFor(member.classId, member.specializationId, member.specialization);
const decisionIndex = rotation?.abilityPriority.indexOf(decision.ability.name) ?? -1;
if (!rotation || decisionIndex <= 0) return false;
return rotation.abilityPriority.slice(0, decisionIndex).some((name) => {
const candidate = learned.find((ability) => ability.name === name);
if (!candidate || candidate.passive) return false;
if (!RESERVED_SIGNATURE_ACTIONS.has(candidate.name)) return false;
if (isComboFinisher(candidate)
&& (member.comboPoints < 4 || member.comboTargetId !== decision.targetId)) return false;
if ((member.cooldowns[candidate.id] ?? 0) > now) return false;
if ((member.schoolLockedUntil[candidate.castRules?.school ?? "physical"] ?? 0) > now) return false;
const categories = aiHintsForAbility(candidate).categories;
if (!categories.includes("damage") || categories.includes("execute")) return false;
const blockedCosts = partyAbilityCosts(member, candidate).filter((cost) => (
cost.amount > currentResource(member, cost.resource)
));
if (!blockedCosts.length) return false;
return blockedCosts.some((cost) => !decision.ability.effects.some((effect) => (
effect.kind === "resource"
&& (effect.resource ?? member.resourceType) === cost.resource
&& effect.amount > 0
)));
});
}
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)]),
);
const learned = learnedAbilities(member, context.now);
const readyAbilities = learned.filter((ability) => !(member.classId === "witch-doctor"
&& member.specializationId === "witch-doctor:brewing" && member.role === "healer"
&& coaWitchDoctorIngredientIdForAbility(ability) !== null)).filter((ability) => partyAbilityIsReady(
member,
ability,
context.now,
Boolean(context.nextHealingSpellFree && ability.effects.some((effect) => effect.kind === "heal")),
));
const linkedKnightPunishment = member.classId === "rom-knight" && member.secondaryClassId === "rom-rogue"
? readyAbilities.find((ability) => ability.source === "runewaker"
&& ability.classId === "rom-knight" && ability.secondaryClassId === "rom-rogue"
&& ability.dbcSpellId === 499909
&& (context.canUseAbility?.(ability) ?? true))
: null;
const rotationAbilities = linkedKnightPunishment
? readyAbilities.filter((ability) => ability.dbcSpellId !== 490151)
: readyAbilities;
const reapingRank = member.classId === "death-knight"
? Math.max(0, Math.trunc(member.talentRanks["talent-2001"] ?? 0))
: 0;
const activeConditionalAbilityNames = new Set(context.availableConditionalAbilityNames ?? []);
const pestilence = learned.find((ability) => ability.name === "Pestilence");
const targetPosition = context.target ? getMobPosition(context.target.id) : null;
const hasNearbyPestilenceTarget = Boolean(targetPosition && pestilence && reapingRank > 0
&& member.specializationId === "death-knight:blood"
&& member.runes.some((rune) => rune.baseType === "blood" && rune.readyAt <= context.now)
&& Object.values(useCombatStore.getState().mobs).some((mob) => {
if (mob.id === context.target?.id || !mob.engaged || mob.dead) return false;
const position = getMobPosition(mob.id);
if (!position) return false;
const dx = position[0] - targetPosition[0];
const dy = position[1] - targetPosition[1];
const dz = position[2] - targetPosition[2];
const radius = pestilence!.radius ?? 10;
return dx * dx + dy * dy + dz * dz <= radius * radius
&& combatHasLineOfSight(targetPosition, position);
}));
const hasCasterOwnedDiseaseOnAnchor = Boolean(context.target && useCombatStore.getState().effects.some((effect) => (
effect.kind === "dot"
&& effect.sourceActorId === member.id
&& effect.targetId === context.target?.id
&& (effect.tag === "blood-plague" || effect.tag === "frost-fever")
&& effect.remainingTicks > 0
&& effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs > context.now
)));
if (hasNearbyPestilenceTarget && hasCasterOwnedDiseaseOnAnchor) {
activeConditionalAbilityNames.add("Pestilence");
}
const decision = evaluateCombatRotation({
now: context.now,
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
specialization: member.specialization,
role: member.role,
statRuleset: member.statRuleset,
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,
deathKnightReapingRank: reapingRank,
abilities: rotationAbilities,
learnedAbilities: learned,
manaCostForAbility: (ability) => partyAbilityCosts(member, ability, context.now)
.filter((cost) => cost.resource === "mana")
.reduce((total, cost) => total + cost.amount, 0),
knownDamagingAbilityNames: new Set(learned.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
}).map((ability) => ability.name)),
hostileTarget: context.target,
friendlyTargets,
hostileCount: context.hostileCount,
targetIsLoose: context.targetIsLoose,
hasDangerousThreat: context.hasDangerousThreat,
enemyUsesMeleeAttacks: context.enemyUsesMeleeAttacks,
incomingMeleeDanger: context.incomingMeleeDanger,
needsEscape: context.needsEscape,
moving: context.moving,
requiredControl: context.requiredControl,
maintainedAbilityIds: context.maintainedAbilityIds,
maintainedPeriodicEffects: useCombatStore.getState().effects.map((effect) => ({
abilityId: effect.sourceAbilityId,
sourceActorId: effect.sourceActorId,
targetId: effect.targetId ?? "__player__",
remainingMs: Math.max(0, effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs - context.now),
})),
activeAuraExclusiveGroups: context.activeAuraExclusiveGroups,
activeSummonAbilityIds: context.activeSummonAbilityIds,
hasActivePet: context.hasActivePet,
partyResourceDeficit: context.partyResourceDeficit,
availableConditionalAbilityNames: activeConditionalAbilityNames.size > 0
? activeConditionalAbilityNames
: undefined,
canUseAbility: context.canUseAbility,
cooldownPolicy: currentCompanionCooldownPolicy(),
incomingDefensiveMechanic: context.incomingDefensiveMechanic,
});
return decision && shouldReserveForHigherPriority(member, learned, decision, context.now)
? null
: decision;
}
export function choosePartyAbility(
member: PartyMember,
context: PartyAbilityContext,
): AbilityDefinition | null {
return choosePartyRotationDecision(member, context)?.ability ?? null;
}
export function commitPartyAbilityUse(
member: PartyMember,
ability: AbilityDefinition,
now: number,
options: { readonly ignoreResourceCosts?: boolean } = {},
): PartyMember | null {
if (!partyAbilityIsReady(member, ability, now, options.ignoreResourceCosts)) return null;
const resourcePools = { ...member.resourcePools };
const lastResourceSpentAt = { ...member.lastResourceSpentAt };
let resource = member.resource;
let health = member.health;
for (const cost of options.ignoreResourceCosts ? [] : partyAbilityCosts(member, ability, now)) {
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;
const romCooldownOverride = partyRomPriestChampionCooldownOverride(member, ability);
const cooldownMs = member.classId === "mage"
? Math.max(0, ability.cooldownMs - wow335MageImprovedCounterspellCooldownReductionMs(
member.talentRanks[WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID] ?? 0,
ability.name,
) - wow335MageImprovedFireBlastCooldownReductionMs(
member.talentRanks[WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID] ?? 0,
ability.name,
))
: romCooldownOverride ?? ability.cooldownMs;
const knightWarriorCooldownReductionMs = partyKnightWarriorWhirlwindAdjustment(member, ability).cooldownReductionMs;
const warriorDruidCooldownReductionMs = partyWarriorDruidWhirlwindAdjustment(member, ability).cooldownReductionMs;
const knightCompassionCooldownReductionMs = partyKnightCompassionCooldownReductionMs(member, ability);
const adjustedCooldownMs = Math.max(0,
cooldownMs - knightWarriorCooldownReductionMs - warriorDruidCooldownReductionMs - knightCompassionCooldownReductionMs);
const cooldowns = { ...member.cooldowns };
if (romCooldownOverride !== null) delete cooldowns[ability.id];
if (adjustedCooldownMs > 0) cooldowns[ability.id] = now + adjustedCooldownMs;
return {
...member,
health,
resource,
resourcePools,
lastResourceSpentAt,
runes,
cooldowns,
globalCooldownEndsAt: ability.gcdMs > 0 ? now + ability.gcdMs : member.globalCooldownEndsAt,
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
? now + Math.max(8_000, ability.cooldownMs)
: member.tauntReadyAt,
};
}
export function partyAbilityAmount(
member: PartyMember,
ability: AbilityDefinition,
effect: AbilityEffect,
poisonDoses = 0,
intellectOverride?: number,
effectiveNativeStats?: RomNativeDerivedStats,
nativeTargetContext?: RomNativeDamageTargetContext,
disabledWeaponChannels?: ReadonlySet<EnemyDisarmWeaponChannel>,
): number {
const sourceIntellect = intellectOverride ?? deriveCharacterStats(
member.classId,
member.level,
member.gearStats,
member.raceId,
{},
member.secondaryClassId,
member.gender,
member.statRuleset ?? "wow",
).intellect;
if (effect.kind === "finisher-damage" && effect.poison) {
const consumedDoses = Math.max(0, Math.min(
effect.poison.maximumDoses,
Math.floor(poisonDoses),
));
return equipmentAdjustedCombatAmount(
{
coefficient: 0,
basePoints: effect.poison.basePointsPerDose * consumedDoses,
dieSides: 1,
attackPowerCoefficient: effect.poison.attackPowerCoefficientPerDose * consumedDoses,
},
member.level,
member.gearStats,
member.classId,
ability,
0,
sourceIntellect,
member.raceId,
member.gender,
member.statRuleset ?? "wow",
member.secondaryClassId,
undefined,
undefined,
disabledWeaponChannels,
);
}
const coefficient = "coefficient" in effect
? effect.coefficient
: effect.kind === "finisher-damage"
? effect.baseCoefficient + effect.perComboCoefficient * member.comboPoints
: 0;
const baseAmount = equipmentAdjustedCombatAmount(
"coefficient" in effect ? effect : coefficient,
member.level,
member.gearStats,
member.classId,
ability,
0,
sourceIntellect,
member.raceId,
member.gender,
member.statRuleset ?? "wow",
member.secondaryClassId,
effectiveNativeStats,
nativeTargetContext,
disabledWeaponChannels,
) + (effect.kind === "damage" ? effect.bonusFlat ?? 0 : 0);
if (member.classId === "druid" && (effect.kind === "damage" || effect.kind === "dot")) {
return applyTalentModifierValue(
baseAmount,
talentModifiersForRanks(member.talentRanks, "druid"),
"damage-percent",
{ abilityName: ability.name },
);
}
if (member.classId === "mage" && effect.kind === "damage") {
return wow335MageImprovedFireBlastDamage(
baseAmount,
member.talentRanks[WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID] ?? 0,
ability.name,
);
}
return baseAmount;
}