691 lines
30 KiB
TypeScript
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;
|
|
}
|