Update Healer Man server

This commit is contained in:
phenom committed 2026-10-06 22:25:34 -04:00
1 parent 6114a2295f
commit 69809f7228
593 files changed
+903643 -40774

No files matched your search

+288 -38
View File
@@ -1,5 +1,15 @@
import { nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
import { classifyEnemyNpcSpellBlockPhase } from "./enemyBlocking";
import { isWow335ShieldBashDazeParent, wow335ShieldBashDazeTargetEligible } from "./wow335ShieldBashDaze";
import {
ENEMY_DISARM_UNARMED_MAIN_HAND,
activeEnemyDisarmChannels,
enemyDisarmAbilityWeaponChannel,
enemyDisarmBlocksAbilityCast,
enemyDisarmRequirementCandidates,
} from "./enemyDisarmRuntime";
import type { EnemyDisarmWeaponChannel } from "./enemyWeaponDebuffs";
import {
abilitiesForClass,
abilitiesForCharacter,
@@ -24,6 +34,7 @@ import {
PLAYER_AURA_ENTITY_ID,
abilityDamageSchool,
auraAdjustedCharacterStats,
effectiveActorAuras,
equipmentAdjustedCombatAmount,
refreshPriestPainAndSufferingDot,
stableCombatUnit,
@@ -40,6 +51,8 @@ import {
wow335HunterGoForTheThroatAttackKindForAbility,
} from "./wow335HunterGoForTheThroatRuntime";
import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority";
import { wow335DruidShapeshiftFormForAuras } from "./wow335DruidMasterShapeshifterRuntime";
import { normalizeCharacterStatRuleset } from "./characterStatRuleset";
import type { MobVector3 } from "./mobPopulation";
import type { WeaponStats } from "./itemStats";
import { requireDungeonDefinition } from "./dungeonRegistry";
@@ -55,7 +68,13 @@ import {
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
import { baseClassFor, classById, isRomClassId } from "../app/characterCatalog";
import { useGameStore } from "./store";
import { coaBarbarianDecapitateTargetAllowed } from "./coaBarbarianExecuteRuntime";
import { coaBarbarianBeheaderEnergyRefund, coaBarbarianDecapitateTargetAllowed, isCoaBarbarianDecapitate } from "./coaBarbarianExecuteRuntime";
import { coaBarbarianDecapitateWeaponStrike, coaNormalizedMeleeWeaponSpeed } from "./coaBarbarianWeaponDamageRuntime";
import { coaWitchDoctorBrewingAbilityForCast, coaWitchDoctorCanBrewIngredient } from "./coaWitchDoctorBrewingRuntime";
import { coaBrewingPreparationAbility } from "./coaWitchDoctorBrewingAi";
import { applyCoaGuardianVanguardEvent } from "./coaGuardianVanguardRuntime";
import { applyCoaGuardianParagonEvent, projectCoaGuardianParagonAbility } from "./coaGuardianParagonRuntime";
import { coaGuardianFormationAbilityForCast, hasCoaGuardianFormationAura } from "./coaGuardianFormationRuntime";
import { useManastormStore } from "./manastormStore";
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import {
@@ -173,6 +192,7 @@ import { emitRotationDecision } from "./rotationTelemetry";
import { imminentCooldownMechanic } from "./companionCooldownPolicy";
import { normalizeActorResourcePool, type DeathKnightRuneState } from "./combatActors";
import { enemyControlPreventsActions } from "./enemyAttacks";
import { enemyControlAbilityCanEscapeAuras } from "./enemyControlImmunityPurge";
import { advanceCombatResourcePool } from "./combatResources";
import {
advanceRomNativeRecoveryClock,
@@ -1022,25 +1042,41 @@ function partyAbilityAmountWithHolyBlessing(
member.gender,
member.statRuleset ?? "wow",
);
const auras = state.auras.filter((aura) => aura.targetId === member.id
&& (aura.expiresAt === null || aura.expiresAt > now));
const channel = ability.wowSpellDefense
? enemyDisarmAbilityWeaponChannel({
damageClass: ability.wowSpellDefense.damageClass,
attributes0: ability.wowSpellDefense.attributes0,
attributes3: ability.wowSpellDefense.attributes3,
equippedItemSubclassMask: ability.wowWeaponRequirements?.equippedItemSubclassMask,
})
: undefined;
const auras = effectiveActorAuras(state.auras, member.id, now, channel)
.filter((aura) => aura.targetId === member.id);
const feralMainHand = member.classId === "druid"
&& ["cat", "bear"].includes(wow335DruidShapeshiftFormForAuras(state.auras, member.id, now) ?? "");
const disabledWeaponChannels = !feralMainHand
? activeEnemyDisarmChannels(state.auras, member.id, now)
: undefined;
const intellect = ["intellect", "all-primary", "all-primary-attributes"].reduce(
(value, stat) => resolveAuraStatValue(value, auras, stat),
base.intellect,
);
if (member.statRuleset === "rom") {
if (normalizeCharacterStatRuleset(member.statRuleset, member.classId) === "rom") {
return partyAbilityAmount(member, ability, effect, poisonDoses, intellect,
auraAdjustedCharacterStats(base, state.auras, member.id, now).romNative, nativeTargetContext);
auraAdjustedCharacterStats(base, auras, member.id, now).romNative, nativeTargetContext,
disabledWeaponChannels);
}
if (member.classId !== "rom-priest") {
return partyAbilityAmount(member, ability, effect, poisonDoses, intellect);
return partyAbilityAmount(member, ability, effect, poisonDoses, intellect,
undefined, undefined, disabledWeaponChannels);
}
const bonus = romPriestHolyBlessingStats(base, auras).spellPowerBonus;
if (bonus <= 0) return partyAbilityAmount(member, ability, effect, poisonDoses, intellect);
if (bonus <= 0) return partyAbilityAmount(member, ability, effect, poisonDoses, intellect,
undefined, undefined, disabledWeaponChannels);
return partyAbilityAmount({
...member,
gearStats: { ...member.gearStats, spellPower: member.gearStats.spellPower + bonus },
}, ability, effect, poisonDoses, intellect);
}, ability, effect, poisonDoses, intellect, undefined, undefined, disabledWeaponChannels);
}
function partyWarlockAbilityAmount(
@@ -1214,8 +1250,10 @@ export function partyAttackDamage(
member: Pick<PartyMember, "level" | "role">
& Partial<Pick<PartyMember,
"id" | "classId" | "specialization" | "gearStats" | "raceId" | "gender" | "statRuleset" | "secondaryClassId"
>>,
>>,
now = Date.now(),
weaponOverride?: WeaponStats,
disarmedNativeMainHand = false,
): number {
const coefficient = member.role === "tank" ? 0.85 : member.role === "healer" ? 0.55 : 1.25;
const gear = member.gearStats;
@@ -1228,7 +1266,12 @@ export function partyAttackDamage(
})
? baseClassFor(classId) === "hunter" ? "ranged" : "spell"
: "melee";
const nativeStats = member.statRuleset === "rom" && isRomClassId(classId)
const attackChannel: EnemyDisarmWeaponChannel | undefined = kind === "ranged"
? "ranged" : kind === "melee" ? "main-hand" : undefined;
const actorAuras = member.id
? effectiveActorAuras(useCombatStore.getState().auras, member.id, now, attackChannel)
: useCombatStore.getState().auras;
const nativeStats = normalizeCharacterStatRuleset(member.statRuleset, classId) === "rom" && isRomClassId(classId)
? auraAdjustedCharacterStats(deriveCharacterStats(
classId,
member.level,
@@ -1238,11 +1281,16 @@ export function partyAttackDamage(
member.secondaryClassId,
member.gender ?? "male",
"rom",
), useCombatStore.getState().auras, member.id ?? "", now).romNative
), actorAuras, member.id ?? "", now).romNative
: undefined;
const nativeAuto = kind !== "spell" ? nativeTrainingAutoAttackProfile(nativeStats, kind === "ranged" ? "ranged" : "main-hand") : undefined;
const nativeSlot = kind === "ranged" ? "ranged" : "main-hand";
const nativeAuto = kind === "spell" ? undefined
: disarmedNativeMainHand && kind === "melee"
? nativeDisarmedMainHandAutoAttackProfile(nativeStats)
?? nativeTrainingAutoAttackProfile(nativeStats, nativeSlot)
: nativeTrainingAutoAttackProfile(nativeStats, nativeSlot);
if (nativeAuto) return Math.max(1, Math.round(nativeAuto.damage));
const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null;
const weapon = weaponOverride ?? (kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null);
const equipmentPower = nativeStats
? kind === "spell"
? nativeStats.magicalDamage
@@ -1482,6 +1530,9 @@ function commitPartyAbilityUseWithRogueCooldown(
now: number,
options: Parameters<typeof commitPartyAbilityUse>[3] = {},
): PartyMember | null {
if (!coaWitchDoctorCanBrewIngredient(
ability, useCombatStore.getState().auras, member.id, member.talentRanks, now,
)) return null;
const naturePowerSpent = options.ignoreResourceCosts ? 0 : partyAbilityCosts(member, ability, now)
.filter((cost) => cost.resource === "nature-power" && cost.amount > 0)
.reduce((total, cost) => total + cost.amount, 0);
@@ -1775,13 +1826,24 @@ function tankPriorityTargetId(
return loose[0]?.id ?? fallbackTargetId;
}
function memberSwingMs(member: PartyMember, now: number): number {
function memberSwingMs(member: PartyMember, now: number, disarmedMainHand = false): number {
const attackChannel: EnemyDisarmWeaponChannel | undefined = member.classId === "rom-scout"
|| (partyUsesRangedAttacks(member) && baseClassFor(member.classId) === "hunter")
? "ranged"
: partyUsesRangedAttacks(member) ? undefined : "main-hand";
const sourceAuras = effectiveActorAuras(
useCombatStore.getState().auras, member.id, now, attackChannel,
);
const native = member.statRuleset === "rom" ? auraAdjustedCharacterStats(deriveCharacterStats(
member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, "rom",
), useCombatStore.getState().auras, member.id, now).romNative : undefined;
), sourceAuras, member.id, now).romNative : undefined;
const physicalAuto = !partyUsesRangedAttacks(member) || baseClassFor(member.classId) === "hunter";
const nativeSlot = member.classId === "rom-scout" ? "ranged" : "main-hand";
const nativeAuto = physicalAuto
? nativeTrainingAutoAttackProfile(native, member.classId === "rom-scout" ? "ranged" : "main-hand")
? disarmedMainHand && native && nativeSlot === "main-hand"
? nativeDisarmedMainHandAutoAttackProfile(native)
?? nativeTrainingAutoAttackProfile(native, nativeSlot)
: nativeTrainingAutoAttackProfile(native, nativeSlot)
: undefined;
const base = member.role === "tank"
? 1_800
@@ -1801,12 +1863,19 @@ function memberSwingMs(member: PartyMember, now: number): number {
member.level,
member.classId,
);
const memberAuras = useCombatStore.getState().auras.filter((aura) => (
const memberAuras = sourceAuras.filter((aura) => (
aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)
));
const effectiveHaste = resolveAuraStatValue(hasteRating, memberAuras, hasteStat);
if (nativeAuto) return Math.max(500, Math.round(hastedDuration(nativeAuto.intervalMs, effectiveHaste)
/ raceAffixHasteMultiplier(now)));
if (disarmedMainHand && normalizeCharacterStatRuleset(member.statRuleset, member.classId) === "wow") {
return Math.max(500, Math.round(hastedDuration(
ENEMY_DISARM_UNARMED_MAIN_HAND.speedMs,
effectiveHaste,
500,
) / raceAffixHasteMultiplier()));
}
return Math.round(hastedDuration(
base,
effectiveHaste,
@@ -1887,10 +1956,12 @@ function partyRogueOffhandAttackProfile(
member.gender,
member.statRuleset ?? "wow",
);
const auras = useCombatStore.getState().auras.filter((aura) => (
aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)
));
const nativeAuto = nativeTrainingAutoAttackProfile(auraAdjustedCharacterStats(stats, useCombatStore.getState().auras, member.id, now).romNative, "off-hand");
const auras = effectiveActorAuras(
useCombatStore.getState().auras, member.id, now, "off-hand",
).filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now));
const nativeAuto = nativeTrainingAutoAttackProfile(auraAdjustedCharacterStats(
stats, auras, member.id, now,
).romNative, "off-hand");
const meleeHastePercent = resolveAuraStatValue(stats.meleeHastePercent, auras, "melee-haste");
if (nativeAuto) return { damage: Math.max(1, Math.round(nativeAuto.damage
* (masteryActive ? 1 + ROM_ROGUE_OFF_HAND_MASTERY_DAMAGE_PERCENT / 100 : 1))),
@@ -1989,9 +2060,9 @@ function partyShamanWeaponAttackProfile(
member.gender,
member.statRuleset ?? "wow",
);
const auras = useCombatStore.getState().auras.filter((aura) => (
aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)
));
const auras = effectiveActorAuras(
useCombatStore.getState().auras, member.id, now, hand,
).filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now));
const attackPower = partyShamanAttackPower(stats.attackPower, auras);
const meleeHastePercent = resolveAuraStatValue(stats.meleeHastePercent, auras, "melee-haste");
const fullWeaponDamage = (weapon.damageMin + weapon.damageMax) / 2
@@ -2030,9 +2101,9 @@ function partyShamanWeaponHitDamage(
member.gender,
member.statRuleset ?? "wow",
);
const auras = useCombatStore.getState().auras.filter((aura) => (
aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)
));
const auras = effectiveActorAuras(
useCombatStore.getState().auras, member.id, now, hand,
).filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now));
const fullWeaponDamage = (weapon.damageMin + weapon.damageMax) / 2
+ partyShamanAttackPower(stats.attackPower, auras) / 14 * (weapon.speedMs / 1_000);
return Math.max(1, Math.round(fullWeaponDamage * (hand === "off-hand" ? 0.5 : 1)));
@@ -2231,6 +2302,23 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
|| Math.abs(origin[1] - targetPosition[1]) > 8
|| !combatHasLineOfSight(origin, targetPosition)
|| (spatial && !isPositionInFacingArc(spatial, targetPosition, Math.PI))) return member;
const disarmedWeaponChannels = activeEnemyDisarmChannels(
useCombatStore.getState().auras, member.id, now,
);
const sourceFeralForm = member.classId === "druid"
? wow335DruidShapeshiftFormForAuras(useCombatStore.getState().auras, member.id, now)
: undefined;
const sourceRuleset = normalizeCharacterStatRuleset(member.statRuleset, member.classId);
const hasPhysicalMainHandAuto = !partyUsesRangedAttacks(member)
|| (member.classId === "shaman" && hasMaelstromWeaponTalent);
const sourceCanUseMainHand = hasPhysicalMainHandAuto
&& !(sourceFeralForm === "cat" || sourceFeralForm === "bear");
const sourceDisarmedMainHand = sourceRuleset === "wow"
&& sourceCanUseMainHand
&& disarmedWeaponChannels.has("main-hand");
const sourceDisarmedNativeMainHand = sourceRuleset === "rom"
&& sourceCanUseMainHand
&& disarmedWeaponChannels.has("main-hand");
const rogueOffhandWeapon = partyRogueOffhandWeapon(member);
if (rogueOffhandWeapon) {
const masteryActive = partyRogueOffHandMasteryActive(member, now);
@@ -2283,11 +2371,20 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
const isDualWielding = hands.some(({ hand }) => hand === "off-hand");
for (const { hand, weapon } of hands) {
const deadlineKey = `${member.id}:${hand}`;
const swingWeapon = hand === "main-hand" && sourceDisarmedMainHand
? ENEMY_DISARM_UNARMED_MAIN_HAND
: weapon;
if (disarmedWeaponChannels.has(hand) && hand !== "main-hand") {
if (now >= (nextShamanWeaponAttackAt.get(deadlineKey) ?? 0)) {
nextShamanWeaponAttackAt.set(deadlineKey, now + partyShamanWeaponAttackProfile(member, weapon, hand, now).intervalMs);
}
continue;
}
if (now < (nextShamanWeaponAttackAt.get(deadlineKey) ?? 0)) continue;
const flurryActiveBeforeSwing = wow335ShamanFlurryIsActive(
useCombatStore.getState().auras, member.id, now,
);
const profile = partyShamanWeaponAttackProfile(member, weapon, hand, now);
const profile = partyShamanWeaponAttackProfile(member, swingWeapon, hand, now);
const abilityId = hand === "main-hand" ? "party-auto-attack" : "party-auto-attack-offhand";
const seed = `${member.id}:${targetId}:${abilityId}:${now}:${hand}`;
const result = useCombatStore.getState().damageMob(
@@ -2314,7 +2411,7 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
nextShamanWeaponAttackAt.set(deadlineKey, now + nextIntervalMs);
if ((result.combatResult?.landed ?? result.amount > 0) && result.amount > 0) {
maybeProcPartyShamanMaelstromWeapon(
member, targetId, abilityId, hand, weapon.speedMs, now,
member, targetId, abilityId, hand, swingWeapon.speedMs, now,
);
}
for (const phase of ["release", "impact"] as const) {
@@ -2326,7 +2423,15 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
return member;
}
if (now < (nextWeaponAttackAt.get(member.id) ?? 0)) return member;
nextWeaponAttackAt.set(member.id, now + memberSwingMs(member, now));
const ownerAttackChannel = partyUsesRangedAttacks(member) && baseClassFor(member.classId) === "hunter"
? "ranged" : "main-hand";
if (ownerAttackChannel === "ranged" && disarmedWeaponChannels.has(ownerAttackChannel)) {
nextWeaponAttackAt.set(member.id, now + memberSwingMs(member, now));
return member;
}
nextWeaponAttackAt.set(member.id, now + memberSwingMs(
member, now, sourceDisarmedMainHand || sourceDisarmedNativeMainHand,
));
const scoutRogueRangedAutoAttack = isRomScoutRogueBloodArrowOwner(member)
&& partyUsesRangedAttacks(member);
const bloodArrowAutoAttackCost = scoutRogueRangedAutoAttack
@@ -2369,12 +2474,20 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
const forgeAutoAttackActive = forgeRankOneActive || forgeRankTwoActive;
const needsAttackRoll = scentRank > 0 || bloodCakedBladeRank > 0 || bloodyVengeanceRank > 0
|| unbridledWrathRank > 0 || goForTheThroatRank > 0 || rogueDualWielding || forgeAutoAttackActive;
const weaponSpeedMs = member.gearStats.meleeWeapon?.speedMs ?? 2_400;
const ownerMainHandWeapon = sourceDisarmedMainHand
? ENEMY_DISARM_UNARMED_MAIN_HAND
: member.gearStats.meleeWeapon;
const weaponSpeedMs = ownerMainHandWeapon?.speedMs ?? 2_400;
const seed = `${member.id}:${targetId}:${abilityId}:${now}:${rogueDualWielding
? "rom-rogue-dual-wield-main-hand"
: forgeAutoAttackActive ? "rom-champion-forge-auto-attack" : "scent-of-blood-auto-attack"}`;
useCombatStore.getState().notifyOwnerHostileAction(member.id, targetId);
const baseOwnerAttackDamage = partyAttackDamage(member, now);
const baseOwnerAttackDamage = partyAttackDamage(
member,
now,
sourceDisarmedMainHand ? ENEMY_DISARM_UNARMED_MAIN_HAND : undefined,
sourceDisarmedNativeMainHand,
);
const bloodArrowRangedAuras = scoutRogueRangedAutoAttack
? activeAuras.filter((aura) => aura.sourceId === member.id
&& aura.targetId === member.id
@@ -2395,7 +2508,15 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
abilityId,
rules: { isAutoAttack: true, ...(rogueDualWielding ? { isDualWielding: true } : {}) },
weaponSpeedMs,
} : undefined);
} : undefined, !needsAttackRoll
? ownerAttackChannel === "ranged" ? "ranged"
: !partyUsesRangedAttacks(member) ? "mainhand" : undefined
: undefined);
if (member.classId === "guardian" && (result.combatResult?.landed ?? result.amount > 0)) {
useCombatStore.setState((state) => ({ auras: [...applyCoaGuardianVanguardEvent(state.auras, {
id: member.id, classId: member.classId, talentRanks: member.talentRanks,
}, "auto-attack-landed", now)] }));
}
if (member.classId === "death-knight"
&& (result.combatResult?.landed ?? result.amount > 0)
&& result.amount > 0) {
@@ -2484,6 +2605,30 @@ export function partyAbilityTargetIsValid(
castCanMove = false,
): boolean {
const combat = useCombatStore.getState();
if (ability.wowWeaponRequirements) {
const loadout = 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,
});
if (enemyDisarmBlocksAbilityCast(
combat.auras,
member.id,
now,
{
...ability.wowWeaponRequirements,
attributes3: ability.wowSpellDefense?.attributes3,
passive: ability.passive,
createsAura: ability.effects.some((effect) => effect.kind === "apply-aura"),
},
enemyDisarmRequirementCandidates(loadout),
)) return false;
}
if (!coaWitchDoctorCanBrewIngredient(ability, combat.auras, member.id, member.talentRanks, now)) return false;
if (member.classId === "warrior" && ability.name === "Victory Rush"
&& !combat.auras.some((aura) => aura.targetId === member.id
&& (aura.expiresAt === null || aura.expiresAt > now)
@@ -2837,6 +2982,20 @@ function choosePartySupportIntent(
return { ability: stateAbility, targetId: member.id };
}
}
if (member.classId === "guardian" && member.role === "tank"
&& member.specializationId === "guardian:vanguard"
&& now >= member.nextActionAt
&& !hasCoaGuardianFormationAura(combat.auras, member, "tower", now)
&& !hasCoaGuardianFormationAura(combat.auras, member, "line", now)) {
const source = abilitiesForCharacter(member.classId, member.secondaryClassId)
.find((ability) => ability.dbcSpellId === 800317);
const learned = source ? learnedAbilityForBuild(source, member.level, member.talentRanks) : null;
const tower = learned ? coaGuardianFormationAbilityForCast(learned) : null;
if (tower && partyAbilityIsReady(member, tower, now)
&& partyAbilityTargetIsValid(member, tower, member.id, undefined, now)) {
return { ability: tower, targetId: member.id };
}
}
if (member.role !== "healer") return null;
const dispelTargetId = [
...(!simulatedPartyExcludesPlayer() && combat.health > 0 ? [PLAYER_AGGRO_ID] : []),
@@ -2852,6 +3011,21 @@ function choosePartySupportIntent(
return { ability, targetId: dispelTargetId };
}
}
if (member.classId === "witch-doctor" && member.specializationId === "witch-doctor:brewing") {
const allies = [
...members.map((ally) => ({ health: ally.health, maxHealth: ally.maxHealth })),
...(!simulatedPartyExcludesPlayer() ? [{ health: combat.health, maxHealth: combat.maxHealth }] : []),
];
const learned = abilitiesForCharacter(member.classId, member.secondaryClassId)
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null);
const brewing = coaBrewingPreparationAbility(member, learned, combat.auras, allies, now,
Boolean(fallenTargetId || dispelTargetId || Object.values(combat.mobs).some((mob) => mob.engaged && !mob.dead && mob.activeCast)));
if (brewing && partyAbilityIsReady(member, brewing, now)
&& partyAbilityTargetIsValid(member, brewing, member.id, undefined, now)) {
return { ability: brewing, targetId: member.id };
}
}
// Preventive damage-taken procs (Earth Shield / Prayer of Mending-shaped
// effects) belong on the tank before damage lands. Normal HoTs and direct
// heals still wait for the injured-target path below.
@@ -3168,6 +3342,18 @@ function executePartyAbility(
illuminationBaseManaCostSnapshot?: number,
): PartyAbilityExecutionResult {
let combat = useCombatStore.getState();
ability = coaWitchDoctorBrewingAbilityForCast(ability, combat.auras, member.id, now);
ability = coaGuardianFormationAbilityForCast(ability);
ability = projectCoaGuardianParagonAbility(ability, member, combat.auras, now);
if (isCoaBarbarianDecapitate(ability)) {
const mainHand = 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"];
ability = coaBarbarianDecapitateWeaponStrike(ability, member.gearStats.meleeWeapon,
mainHand ? coaNormalizedMeleeWeaponSpeed(mainHand) : null);
}
if (ability.source === "runewaker") {
for (const passive of abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((candidate) => candidate.passive)
@@ -3420,7 +3606,9 @@ function executePartyAbility(
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")),
}));
}
for (const effect of castEffects) {
const utilityHit = hostileTargets.includes(targetId)
? useCombatStore.getState().resolveMobUtilitySpellHit(targetId, member.id, ability, now) : null;
for (const effect of utilityHit?.blocked ? [] : castEffects) {
const darkEnergyStrikeGroup = effect.kind === "damage" && isRomChampionDarkEnergyStrike
? romChampionDarkEnergyStrikePacketGroupAtDamageIndex(romChampionDarkEnergyStrikeDamageIndex++)
: null;
@@ -3624,7 +3812,15 @@ function executePartyAbility(
&& combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes(ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG)
&& (aura.expiresAt === null || aura.expiresAt > now));
const partyUsesCombatRollContext = member.statRuleset === "rom" || drumsOfWarShot || lightOfDayHit || deathKnightSuddenDoomTrigger
const sourceNpcSpellTarget = combat.mobs[hostileTargetId];
const sourceNpcSpellAttackKind: WotlkAttackKind | null = !channelPulse
&& effect.kind === "damage" && spellSchool === "physical" && ability.wowSpellDefense
&& sourceNpcSpellTarget?.serverEntry && !sourceNpcSpellTarget.characterDefender
&& sourceNpcSpellTarget.runewakerNpcId === undefined && !sourceNpcSpellTarget.summonedBy
&& classifyEnemyNpcSpellBlockPhase(ability.wowSpellDefense) === "direct-damage"
? ability.wowSpellDefense.damageClass === 3 ? "ranged" : "melee" : null;
const partyUsesCombatRollContext = sourceNpcSpellAttackKind !== null
|| member.statRuleset === "rom" || drumsOfWarShot || lightOfDayHit || deathKnightSuddenDoomTrigger
|| deathKnightScentOfBloodMeleeHit
|| deathKnightBloodyVengeanceHit
|| deathKnightBloodGorgedHit
@@ -3737,7 +3933,7 @@ function executePartyAbility(
? romNativePlayerHitProfile(effect.romNativeSource.skillId, effect.romNativeSource.componentId)?.damageChannel === "physical"
? member.classId === "rom-scout" ? "ranged" : "melee" : "spell"
: drumsOfWarShot ? "ranged"
: hunterGoForTheThroatAttackKind
: sourceNpcSpellAttackKind ?? hunterGoForTheThroatAttackKind
?? (deathKnightScentOfBloodMeleeHit || deathKnightBloodGorgedHit
|| shamanWeaponMeleeHit || godsSwordDirectPhysicalHit || darkEnergyStrikePhysicalHit
|| (deathKnightBloodyVengeanceHit && spellSchool === "physical")
@@ -3780,6 +3976,18 @@ function executePartyAbility(
}
effectiveAmount += result.amount;
const partyHitLanded = result.combatResult?.landed ?? result.amount > 0;
const beheaderRefund = !channelPulse && effect.kind === "damage"
? coaBarbarianBeheaderEnergyRefund(ability, member.talentRanks, companionResourceMaximum(runtimeMember, "energy"), result)
: 0;
if (beheaderRefund > 0) {
const maximumEnergy = companionResourceMaximum(runtimeMember, "energy");
const energy = Math.min(maximumEnergy, (runtimeMember.resourcePools.energy ?? runtimeMember.resource) + beheaderRefund);
runtimeMember = {
...runtimeMember,
...(runtimeMember.resourceType === "energy" ? { resource: energy } : {}),
resourcePools: { ...runtimeMember.resourcePools, energy },
};
}
if (!partyUsesCombatRollContext && effect.kind === "damage" && partyHitLanded && result.amount > 0) {
const learnedSoulBrandPassives = member.classId === "rom-warlock"
&& member.secondaryClassId === "rom-mage"
@@ -4502,7 +4710,8 @@ function executePartyAbility(
applyPartyMageArcanePotency(member, ability, "presence-of-mind", now);
}
if (effect.control && useCombatStore.getState().mobs[recipientId]) {
useCombatStore.getState().controlMob(recipientId, ability.id, effect.control.kind, appliedEffect.aura.durationMs ?? 1_000, now);
useCombatStore.getState().controlMob(recipientId, ability.id, effect.control.kind,
appliedEffect.aura.durationMs ?? 1_000, now, false, member.id, effect.control.magnitude);
}
}
} else if (effect.kind === "dispel") {
@@ -4734,6 +4943,8 @@ function executePartyAbility(
resource: resource === runtimeMember.resourceType ? resourcePools[resource] ?? runtimeMember.resource : runtimeMember.resource,
};
} else if (effect.kind === "trigger-spell" && !effect.expanded && depth < 2) {
if (effect.spellId === 29703 && (!isWow335ShieldBashDazeParent(ability)
|| !wow335ShieldBashDazeTargetEligible(useCombatStore.getState().mobs[targetId]))) continue;
const triggered = abilitiesForClass(member.classId).find((candidate) => candidate.dbcSpellId === effect.spellId)
?? triggeredAbilityBySpellId(effect.spellId);
if (triggered && triggered.id !== ability.id) {
@@ -4913,6 +5124,11 @@ function executePartyAbility(
}
}
if (!channelPulse && depth === 0) applyPartyWarlockBackdraft(member, ability, now);
if (!channelPulse && depth === 0) {
useCombatStore.setState((state) => ({ auras: [...applyCoaGuardianParagonEvent(
state.auras, runtimeMember, { type: "cast", ability, result: "success" }, now,
).auras] }));
}
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) {
@@ -5185,6 +5401,40 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
member.controlledUntil > now
&& enemyControlPreventsActions(member.controlMechanic)
) {
const controlCombat = useCombatStore.getState();
const berserkerRageBase = member.classId === "warrior"
? abilitiesForCharacter(member.classId, member.secondaryClassId)
.find((ability) => ability.dbcSpellId === 18499)
: undefined;
const berserkerRage = berserkerRageBase
? learnedAbilityForBuild(berserkerRageBase, member.level, member.talentRanks)
: null;
const mechanic = member.controlMechanic;
if (berserkerRage && !member.activeCast && (mechanic === "fear" || mechanic === "stun")
&& enemyControlAbilityCanEscapeAuras(
berserkerRage, mechanic, controlCombat.auras, member.id, now,
)
&& partyAbilityTargetIsValid(member, berserkerRage, member.id, undefined, now)) {
const committed = commitPartyAbilityUseWithRogueCooldown(member, berserkerRage, now);
if (committed) {
const execution = executePartyAbility(committed, berserkerRage, member.id, now, nextMembers);
const partyStateAfterGrant = usePartyStore.getState().members.find((candidate) => candidate.id === member.id);
const actorAfterGrant = useCombatStore.getState().actors[member.id];
const controlledUntil = actorAfterGrant?.controlledUntil
?? partyStateAfterGrant?.controlledUntil
?? execution.member.controlledUntil;
const controlMechanic = partyStateAfterGrant?.controlMechanic
?? execution.member.controlMechanic;
nextMembers[index] = {
...execution.member,
controlledUntil,
controlMechanic: controlledUntil > now ? controlMechanic : null,
status: targetId ? member.role === "tank" ? "guarding" : "attacking" : "ready",
nextActionAt: now + Math.max(250, berserkerRage.castTimeMs, berserkerRage.gcdMs),
};
continue;
}
}
nextMembers[index] = member.status === "holding"
? member
: { ...member, status: "holding" };