Update Healer Man server

This commit is contained in:
phenom committed 2026-10-02 10:38:12 -04:00
1 parent 4c768fbec6
commit b16821e92e
330 files changed
+35406 -1760

No files matched your search

+227 -15
View File
@@ -14,9 +14,11 @@ import {
} 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 } from "./wotlkStats";
import { baseResourcesFor, deriveCharacterStats } from "./wotlkStats";
import { companionResourceMaximum } from "./combatActorAuthority";
import { currentCompanionCooldownPolicy, type CompanionCooldownMechanicRule } from "./companionCooldownPolicy";
import { WOW335_MAGE_ARCANE_CONCENTRATION_TAG } from "./wow335MageTalentRuntime";
@@ -24,13 +26,28 @@ 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 { romKnightWarriorWhirlwindAdjustment } from "./romKnightOverrides";
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";
export interface PartyAbilityContext {
readonly now: number;
@@ -52,6 +69,7 @@ export interface PartyAbilityContext {
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;
@@ -59,10 +77,52 @@ export interface PartyAbilityContext {
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,
@@ -112,27 +172,107 @@ function partyKnightWarriorWhirlwindAdjustment(
);
}
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 adjustment = partyKnightWarriorWhirlwindAdjustment(member, ability);
if (adjustment.rageCostReduction <= 0 && adjustment.cooldownReductionMs <= 0) return ability;
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 - adjustment.rageCostReduction) }
? { ...cost, amount: Math.max(0, cost.amount - rageCostReduction) }
: cost
));
return {
...ability,
cooldownMs: Math.max(0, ability.cooldownMs - adjustment.cooldownReductionMs),
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 = partyAbilityWithKnightWarriorWhirlwind(member, ability);
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) => (
@@ -165,14 +305,22 @@ export function partyAbilityIsReady(
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) && (member.cooldowns[ability.id] ?? 0) > now)) return false;
|| (!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"
@@ -184,7 +332,8 @@ export function partyAbilityIsReady(
function learnedAbilities(member: PartyMember): 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): ability is AbilityDefinition => ability !== null)
.map((ability) => partyAbilityWithRomPriestChampionCooldown(member, ability));
}
function isComboFinisher(ability: AbilityDefinition): boolean {
@@ -244,6 +393,37 @@ export function choosePartyRotationDecision(
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
);
const learned = learnedAbilities(member);
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,
@@ -261,6 +441,7 @@ export function choosePartyRotationDecision(
comboPoints: member.comboPoints,
comboTargetId: member.comboTargetId,
runes: member.runes,
deathKnightReapingRank: reapingRank,
abilities: learned.filter((ability) => partyAbilityIsReady(
member,
ability,
@@ -285,7 +466,10 @@ export function choosePartyRotationDecision(
activeAuraExclusiveGroups: context.activeAuraExclusiveGroups,
activeSummonAbilityIds: context.activeSummonAbilityIds,
hasActivePet: context.hasActivePet,
availableConditionalAbilityNames: context.availableConditionalAbilityNames,
partyResourceDeficit: context.partyResourceDeficit,
availableConditionalAbilityNames: activeConditionalAbilityNames.size > 0
? activeConditionalAbilityNames
: undefined,
canUseAbility: context.canUseAbility,
cooldownPolicy: currentCompanionCooldownPolicy(),
incomingDefensiveMechanic: context.incomingDefensiveMechanic,
@@ -324,14 +508,24 @@ export function commitPartyAbilityUse(
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,
))
: ability.cooldownMs;
: romCooldownOverride ?? ability.cooldownMs;
const knightWarriorCooldownReductionMs = partyKnightWarriorWhirlwindAdjustment(member, ability).cooldownReductionMs;
const adjustedCooldownMs = Math.max(0, cooldownMs - knightWarriorCooldownReductionMs);
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,
@@ -339,9 +533,7 @@ export function commitPartyAbilityUse(
resourcePools,
lastResourceSpentAt,
runes,
cooldowns: adjustedCooldownMs > 0
? { ...member.cooldowns, [ability.id]: now + adjustedCooldownMs }
: member.cooldowns,
cooldowns,
globalCooldownEndsAt: ability.gcdMs > 0 ? now + ability.gcdMs : member.globalCooldownEndsAt,
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
? now + Math.max(8_000, ability.cooldownMs)
@@ -354,7 +546,16 @@ export function partyAbilityAmount(
ability: AbilityDefinition,
effect: AbilityEffect,
poisonDoses = 0,
intellectOverride?: number,
): number {
const sourceIntellect = intellectOverride ?? deriveCharacterStats(
member.classId,
member.level,
member.gearStats,
member.raceId,
{},
member.secondaryClassId,
).intellect;
if (effect.kind === "finisher-damage" && effect.poison) {
const consumedDoses = Math.max(0, Math.min(
effect.poison.maximumDoses,
@@ -371,6 +572,8 @@ export function partyAbilityAmount(
member.gearStats,
member.classId,
ability,
0,
sourceIntellect,
);
}
const coefficient = "coefficient" in effect
@@ -384,6 +587,8 @@ export function partyAbilityAmount(
member.gearStats,
member.classId,
ability,
0,
sourceIntellect,
) + (effect.kind === "damage" ? effect.bonusFlat ?? 0 : 0);
if (member.classId === "druid" && (effect.kind === "damage" || effect.kind === "dot")) {
return applyTalentModifierValue(
@@ -393,5 +598,12 @@ export function partyAbilityAmount(
{ 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;
}