Correct minion sacrifice and passive talent semantics (refs #19)

Coordinator Root /root, session 01a12399-cfab-7c72-8455-e0e4cb7e156c.
Independently reviewed by /root/combat_source_finish_luna (GPT-6 Luna xhigh).
31 focused DK tests, required release check and complete source type check pass.
This commit is contained in:
warren committed 2026-10-15 09:09:45 -04:00
1 parent a9322a3897
commit 3687c423e2
11 files changed
+284 -10

No files matched your search

@@ -0,0 +1,37 @@
# Death Pact and Blood Gorged finding corrections
Tracked in [shared issue #19](https://git.whoagland.com/phenom/healer-man/issues/19).
Coordinator: Root `/root`, session `01a12399-cfab-7c72-8455-e0e4cb7e156c`.
The local Death Pact row (48743) supplies a 40% heal, a 100-yard minion-selection
radius, a 40 runic-power cost, a 120-second cooldown and a 1.5-second GCD.
Its hostile instant-kill conversion and tooltip-derived flat heal were wrong.
The explicit sacrifice effect now consumes exactly one living, unexpired undead
minion owned by the caster inside that radius and heals only that caster.
The heal cannot crit. Missing minions fail before resource, cooldown or GCD use.
Sacrifice produces a death record rather than damage-meter or experience credit.
Player and companion dispatch share minion selection and death-state mutation.
The [AzerothCore Death Pact handler](https://github.com/azerothcore/azerothcore-wotlk/blob/master/src/server/scripts/Spells/spell_dk.cpp)
corroborates living, owned undead selection within 100 yards and a single victim.
This runtime admits only the two undead ghoul entries it already models through
Raise Dead (26125) and Army of the Dead (24207). Unknown summon families are not
assumed undead. Other undead minion types require a modeled source identity.
Automated non-healer sacrifices use the existing defensive health threshold;
this is simulator AI policy, not a spell-source trigger.
Blood Gorged's five local ranks contain only learn-spell effects (36), linking
the conditional damage and armor-penetration children. The catalog no longer
invents an active attack from that passive tooltip. Talent wrappers with only
learn effects and no activation cost, cast time or cooldown remain passive;
active and mixed-effect spells remain executable. The generator preserves the
same classification for future imports. Existing sourced Blood Gorged damage
and armor handling remains in `wow335DeathKnightBloodGorgedRuntime.ts`; see
`docs/WOW_DEATH_KNIGHT_BLOOD_GORGED.md` for its health and family-mask boundaries.
Focused validation covers real Raise Dead then Death Pact casts, hostile trash
and bosses, owner isolation, dead/expired/distant/non-undead minions, exact
cost/cooldown/GCD, overheal and noncritical event accounting, actual automatic
companion dispatch, and all five Blood Gorged wrapper ranks. Existing player and
companion Blood Gorged health/mask tests preserve the legitimate passive bonus.
The historical 873-cell campaign and source snapshots are unchanged.
+5 -1
View File
@@ -396,7 +396,11 @@ try {
// with the same name on the class skill line. That proc has no player // with the same name on the class skill line. That proc has no player
// activation mechanics, so retain it for the spellbook but classify it // activation mechanics, so retain it for the spellbook but classify it
// as passive instead of exposing it as a castable action. // as passive instead of exposing it as a castable action.
const passive = passiveTalentNames.has(name) && ranks.every((spell) => ( const passive = (passiveTalentNames.has(name) || (Boolean(talentNodeId)
&& ranks.every((spell) => {
const effects = spell.effects.filter((effect) => effect.effectType !== 0);
return effects.length > 0 && effects.every((effect) => effect.effectType === 36);
}))) && ranks.every((spell) => (
spell.castTimeMs === 0 spell.castTimeMs === 0
&& spell.recoveryTimeMs === 0 && spell.recoveryTimeMs === 0
&& spell.categoryRecoveryTimeMs === 0 && spell.categoryRecoveryTimeMs === 0
+1
View File
@@ -196,6 +196,7 @@ export type AbilityEffect =
| ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource) | ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients) | ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
| { readonly kind: "resurrection"; readonly percentMaxHealth: number; readonly flatHealth?: number } | { readonly kind: "resurrection"; readonly percentMaxHealth: number; readonly flatHealth?: number }
| { readonly kind: "sacrifice-minion"; readonly creatureIds: readonly number[]; readonly radius: number; readonly percentMaxHealth: number }
| { | {
readonly kind: "summon"; readonly kind: "summon";
readonly creatureId: number; readonly creatureId: number;
+1 -1
View File
@@ -96,7 +96,7 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
for (const effect of ability.effects) { for (const effect of ability.effects) {
if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage"); if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage");
if (effect.kind === "execute") categories.add("execute"); if (effect.kind === "execute") categories.add("execute");
if (effect.kind === "heal" || effect.kind === "hot") categories.add("heal"); if (effect.kind === "heal" || effect.kind === "hot" || effect.kind === "sacrifice-minion") categories.add("heal");
if (effect.kind === "shield") categories.add("mitigation"); if (effect.kind === "shield") categories.add("mitigation");
if (effect.kind === "taunt") categories.add("taunt"); if (effect.kind === "taunt") categories.add("taunt");
if (effect.kind === "interrupt") categories.add("interrupt"); if (effect.kind === "interrupt") categories.add("interrupt");
+6
View File
@@ -679,6 +679,12 @@ function scoreAbility(
score += injured.length ? -3_000 : 300; score += injured.length ? -3_000 : 300;
} }
} else { } else {
if (ability.effects.some((effect) => effect.kind === "sacrifice-minion")) {
// Automated minion sacrifices are emergency self-heals; the cast gate
// still requires a living owned source minion before resources are spent.
if (selfHealth >= cooldownPolicy.generic.defensiveHealthPercent / 100) return null;
score += 140_000;
}
if (categories.has("mitigation")) { if (categories.has("mitigation")) {
const defensiveThreshold = cooldownPolicy.generic.defensiveHealthPercent / 100; const defensiveThreshold = cooldownPolicy.generic.defensiveHealthPercent / 100;
const emergencyThreshold = cooldownPolicy.generic.emergencyHealthPercent / 100; const emergencyThreshold = cooldownPolicy.generic.emergencyHealthPercent / 100;
+1 -1
View File
@@ -1536,7 +1536,7 @@ describe("combat store", () => {
registerTarget("pact-boss", [2, 0, 0], 1_000_000, 0); registerTarget("pact-boss", [2, 0, 0], 1_000_000, 0);
useCombatStore.getState().registerMob("pact-boss", { name: "Pact boss", boss: true, maxHealth: 1_000_000, xpReward: 0 }); useCombatStore.getState().registerMob("pact-boss", { name: "Pact boss", boss: true, maxHealth: 1_000_000, xpReward: 0 });
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } }); useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
expect(useCombatStore.getState().castAbility("wow335-death-knight-death-pact", undefined, 1_000).ok).toBe(true); expect(useCombatStore.getState().castAbility("wow335-death-knight-death-pact", undefined, 1_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().mobs["pact-boss"]).toMatchObject({ dead: false, health: 1_000_000 }); expect(useCombatStore.getState().mobs["pact-boss"]).toMatchObject({ dead: false, health: 1_000_000 });
}); });
+54 -1
View File
@@ -1,3 +1,4 @@
import { eligibleSacrificeMinion } from "./sacrificeMinion";
import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery"; import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery";
import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount"; import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount";
import { romNativeNpcCombatant, romNativeTrainingCombatant, romNativePlayerHitProfile, romNativeNpcHitProfile, resolveRomNativeCombatResult, import { romNativeNpcCombatant, romNativeTrainingCombatant, romNativePlayerHitProfile, romNativeNpcHitProfile, resolveRomNativeCombatResult,
@@ -1712,6 +1713,7 @@ export interface CombatState {
controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number, breakOnDamage?: boolean, sourceActorId?: string, magnitude?: number) => boolean; controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number, breakOnDamage?: boolean, sourceActorId?: string, magnitude?: number) => boolean;
dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number; dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number;
healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution; healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution;
sacrificeActorMinion: (ownerActorId: string, abilityId: string, effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>, origin: CombatPosition, now?: number) => boolean;
/** Applies the CoA Holy Candle self-damage trigger to a companion or channel caster. */ /** Applies the CoA Holy Candle self-damage trigger to a companion or channel caster. */
triggerRomPriestHolyCandle: (ownerActorId: string, healingAbilityId: string, now?: number) => number; triggerRomPriestHolyCandle: (ownerActorId: string, healingAbilityId: string, now?: number) => number;
reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number; reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number;
@@ -1822,6 +1824,30 @@ let summonSequence = 0;
let areaEffectSequence = 0; let areaEffectSequence = 0;
let combatEventSequence = 0; let combatEventSequence = 0;
function sacrificeMinionInWork(
work: Pick<MutableCastWork, "summons" | "actors" | "auras" | "effects" | "combatEvents">,
ownerActorId: string,
abilityId: string,
effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>,
origin: CombatPosition,
now: number,
): boolean {
const minion = eligibleSacrificeMinion(work.summons, ownerActorId, effect, origin, now);
if (!minion) return false;
work.summons = work.summons.map((summon) => summon.id === minion.id
? { ...summon, health: 0, targetId: null, cast: null } : summon);
const actor = work.actors[minion.id];
if (actor) work.actors[minion.id] = { ...actor, health: 0, alive: false, cast: null };
work.auras = work.auras.filter((aura) => aura.targetId !== minion.id);
work.effects = work.effects.filter((candidate) => candidate.targetId !== minion.id);
recordCombatEventInWork(work, {
occurredAt: now, kind: "death", sourceActorId: ownerActorId,
targetActorId: minion.id, abilityId, rawAmount: 0, effectiveAmount: 0,
overheal: 0, absorbed: 0, critical: false, outcome: "sacrificed",
});
return true;
}
function initialSummonCombatRuntime(): Pick<CombatSummon, function initialSummonCombatRuntime(): Pick<CombatSummon,
"abilityIds" | "resources" | "cooldowns" | "globalCooldownEndsAt" | "cast" | "controlledUntil" | "schoolLockedUntil" "abilityIds" | "resources" | "cooldowns" | "globalCooldownEndsAt" | "cast" | "controlledUntil" | "schoolLockedUntil"
> { > {
@@ -1908,7 +1934,7 @@ function recordCombatEvent(
return appendCombatEvent(events, input, combatEventSequence); return appendCombatEvent(events, input, combatEventSequence);
} }
function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput): void { function recordCombatEventInWork(work: Pick<MutableCastWork, "combatEvents">, input: CombatEventInput): void {
work.combatEvents = [...recordCombatEvent(work.combatEvents, input)]; work.combatEvents = [...recordCombatEvent(work.combatEvents, input)];
} }
@@ -13665,6 +13691,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met"; && Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
else if (ability.name === "Enraged Regeneration" else if (ability.name === "Enraged Regeneration"
&& !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met"; && !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met";
else if (ability.effects.some((effect) => effect.kind === "sacrifice-minion"
&& !eligibleSacrificeMinion(state.summons, PLAYER_AGGRO_ID, effect, currentOrigin(state, origin), now))) failure = "condition-not-met";
else if (state.classId === "hunter" && ability.name === "Revive Pet" else if (state.classId === "hunter" && ability.name === "Revive Pet"
&& !state.summons.some((summon) => summon.health <= 0 && !state.summons.some((summon) => summon.health <= 0
&& (summon.id.startsWith("player-pet-") && (summon.id.startsWith("player-pet-")
@@ -16879,6 +16907,22 @@ export const useCombatStore = create<CombatState>((set, get) => ({
]); ]);
} }
} }
} else if (effect.kind === "sacrifice-minion") {
if (!sacrificeMinionInWork(work, PLAYER_AGGRO_ID, ability.id, effect, playerOrigin, now)) continue;
const amount = Math.round(resolveAuraHealingValue(
resolveAuraHealingValue(Math.round(work.maxHealth * effect.percentMaxHealth),
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "done"),
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "taken",
));
const healed = healPlayerInWork(work, amount);
work.totalHealing += healed;
recordCombatEventInWork(work, {
occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID,
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, school: spellSchool,
rawAmount: amount, effectiveAmount: healed, overheal: amount - healed,
absorbed: 0, critical: false,
});
addHealingThreatInWork(work, castThreatSource, healed, now);
} else if (effect.kind === "execute") { } else if (effect.kind === "execute") {
for (const id of affectedMobIds) { for (const id of affectedMobIds) {
const mob = work.mobs[id]; const mob = work.mobs[id];
@@ -18676,6 +18720,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return applied; return applied;
}, },
sacrificeActorMinion: (ownerActorId, abilityId, effect, origin, now = combatNow()) => {
const state = get();
const work = { summons: [...state.summons], actors: { ...state.actors },
auras: [...state.auras], effects: [...state.effects], combatEvents: [...state.combatEvents] };
if (!sacrificeMinionInWork(work, ownerActorId, abilityId, effect, origin, now)) return false;
set(work);
return true;
},
healActor: (id, amount, request) => { healActor: (id, amount, request) => {
const now = request.now ?? combatNow(); const now = request.now ?? combatNow();
const state = get(); const state = get();
+16 -2
View File
@@ -1,3 +1,4 @@
import { eligibleSacrificeMinion } from "./sacrificeMinion";
import { healerManaRecoveryAmount } from "./healerManaRecovery"; import { healerManaRecoveryAmount } from "./healerManaRecovery";
import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount"; import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
import { romNativePlayerHitProfile } from "./romNativeCombatResolution"; import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
@@ -2666,6 +2667,8 @@ export function partyAbilityTargetIsValid(
|| (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false; || (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false;
} }
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition; const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
if (ability.effects.some((effect) => effect.kind === "sacrifice-minion"
&& !eligibleSacrificeMinion(combat.summons, member.id, effect, origin, now))) return false;
const meditationAllowsMovement = romMageWarriorMeditationPathAllowsMovement( const meditationAllowsMovement = romMageWarriorMeditationPathAllowsMovement(
member.classId, member.classId,
member.secondaryClassId, member.secondaryClassId,
@@ -3190,7 +3193,7 @@ function choosePartySupportIntent(
lowestHealthFraction: target.health / Math.max(1, target.maxHealth), lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
friendlyTargets, friendlyTargets,
maintainedAbilityIds, maintainedAbilityIds,
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind) canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield", "sacrifice-minion"].includes(effect.kind)
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length))) || (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
&& (healingAbilityCanAidOthers(ability) || target.id === member.id) && (healingAbilityCanAidOthers(ability) || target.id === member.id)
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects) && !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
@@ -4587,6 +4590,17 @@ function executePartyAbility(
ability.id, ability.id,
now, now,
); );
} else if (effect.kind === "sacrifice-minion") {
if (!useCombatStore.getState().sacrificeActorMinion(member.id, ability.id, effect, origin, now)) continue;
const result = useCombatStore.getState().healActor(member.id,
Math.round(member.maxHealth * effect.percentMaxHealth), {
sourceActorId: member.id, abilityId: ability.id, school: spellSchool,
criticalChance: 0, now,
});
effectiveAmount += result.effectiveAmount;
directHealingRawAmount += result.rawAmount;
combat = useCombatStore.getState();
runtimeMember = memberFromCompanionActor(runtimeMember, combat.actors[member.id]);
} else if (effect.kind === "execute") { } else if (effect.kind === "execute") {
for (const hostileTargetId of hostileTargets) { for (const hostileTargetId of hostileTargets) {
const target = useCombatStore.getState().mobs[hostileTargetId]; const target = useCombatStore.getState().mobs[hostileTargetId];
@@ -5147,7 +5161,7 @@ function executePartyAbility(
} }
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted); emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted); emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) { if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection", "sacrifice-minion"].includes(effect.kind))) {
useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount); useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount);
} }
if (suddenDoomTrigger && depth === 0) { if (suddenDoomTrigger && depth === 0) {
+18
View File
@@ -0,0 +1,18 @@
import type { AbilityEffect } from "./abilityCatalog";
import type { CombatSummon } from "./combatStore";
import type { CombatPosition } from "./mobRuntimeRegistry";
/** Select one living, owned, modeled undead minion; hostile actors never qualify. */
export function eligibleSacrificeMinion(
summons: readonly CombatSummon[],
ownerActorId: string,
effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>,
origin: CombatPosition,
now: number,
): CombatSummon | undefined {
return summons.find((summon) => summon.ownerActorId === ownerActorId
&& summon.health > 0 && summon.expiresAt > now
&& effect.creatureIds.includes(summon.creatureId)
&& Math.hypot(summon.position[0] - origin[0], summon.position[1] - origin[1],
summon.position[2] - origin[2]) <= effect.radius);
}
+20 -4
View File
@@ -1633,6 +1633,14 @@ function classScriptEffects(
triggerSpellId: 0, triggerSpellId: 0,
durationMs: 0, durationMs: 0,
}]; }];
case "death-knight:Death Pact": return [{
kind: "sacrifice-minion",
// These are the undead ghoul entries already modeled by Raise Dead and
// Army of the Dead. Unknown summon families are never assumed undead.
creatureIds: [26125, 24207],
radius: rank.effects.find((effect) => effect.effectType === 1)?.radius ?? 0,
percentMaxHealth: Math.abs(rank.effects.find((effect) => effect.effectType === 10)?.basePoints ?? 0) / 100,
}];
case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }]; case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }];
case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({ case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({
kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000, kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000,
@@ -1851,6 +1859,11 @@ export function hunterExplosiveTrapDirectScaling(
}; };
} }
function learnOnlyRank(rank: Wow335Rank): boolean {
const effects = rank.effects.filter((effect) => effect.effectType !== 0);
return effects.length > 0 && effects.every((effect) => effect.effectType === 36);
}
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] { function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
const effects: AbilityEffect[] = []; const effects: AbilityEffect[] = [];
for (const [effectIndex, effect] of rank.effects.entries()) { for (const [effectIndex, effect] of rank.effects.entries()) {
@@ -2150,7 +2163,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
} }
const auraOnlyStance = classId === "death-knight" const auraOnlyStance = classId === "death-knight"
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName); && ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
if (!auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand") if (!learnOnlyRank(rank) && !auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
&& !(classId === "mage" && abilityName === "Molten Armor") && !(classId === "mage" && abilityName === "Molten Armor")
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) { && !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
if (classId === "death-knight" && abilityName === "Death Coil") { if (classId === "death-knight" && abilityName === "Death Coil") {
@@ -2167,6 +2180,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported") .filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
.filter((effect) => !( .filter((effect) => !(
(classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection") (classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection")
|| (classId === "death-knight" && abilityName === "Death Pact" && ["execute", "heal"].includes(effect.kind))
|| (classId === "paladin" && abilityName === "Divine Storm" || (classId === "paladin" && abilityName === "Divine Storm"
&& (effect.kind === "damage" || effect.kind === "heal")) && (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "paladin" && abilityName === "Holy Shield" && effect.kind === "damage") || (classId === "paladin" && abilityName === "Holy Shield" && effect.kind === "damage")
@@ -2523,8 +2537,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes" const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes"
? Math.max(1, firstRaw.sourceLevel || firstRaw.level) ? Math.max(1, firstRaw.sourceLevel || firstRaw.level)
: chain.sourceUnlockLevel; : chain.sourceUnlockLevel;
const target = abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects); const target = first.effects.some((effect) => effect.kind === "sacrifice-minion")
const radius = Math.max( ? "self" : abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects);
const radius = first.effects.some((effect) => effect.kind === "sacrifice-minion") ? 0 : Math.max(
abilityNameRadius(chain.name), abilityNameRadius(chain.name),
...firstRaw.effects.map((effect) => effect.radius), ...firstRaw.effects.map((effect) => effect.radius),
); );
@@ -2579,7 +2594,8 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
gcdMs: first.gcdMs, gcdMs: first.gcdMs,
cost: first.cost, cost: first.cost,
effects: first.effects, effects: first.effects,
passive: chain.passive, passive: chain.passive || (Boolean(chain.talentNodeId) && castRanks.every((rank) => learnOnlyRank(rank) && rank.castTimeMs === 0
&& rank.cooldownMs === 0 && rank.powerCost === 0 && rank.powerCostPercentage === 0)),
source: "wow335-progression", source: "wow335-progression",
// Some stock WotLK active talents remain in Spell.dbc after Ascension's // Some stock WotLK active talents remain in Spell.dbc after Ascension's
// patched Talent.dbc removes their nodes (Devastate, Shadowstep, Lava // patched Talent.dbc removes their nodes (Devastate, Shadowstep, Lava
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { abilitiesForCharacter, abilityAtLevel, type AbilityEffect } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { installCombatClock } from "./combatClock";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, partyAbilityTargetIsValid, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
const PACT = "wow335-death-knight-death-pact";
const RAISE = "wow335-death-knight-raise-dead";
const ability = (id: string) => abilityAtLevel(abilitiesForCharacter("death-knight", null).find(a => a.id === id)!, 80);
let restoreClock: (() => void) | undefined;
function hostile(id: string, boss = false) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0, boss });
setMobPosition(id, [2, 0, 0]);
useCombatStore.getState().selectMob(id);
}
function summon(owner = PLAYER_AGGRO_ID, position: readonly [number, number, number] = [0, 0, 0], creatureId = 26125) {
const source = ability(RAISE);
const effect = source.effects.find(e => e.kind === "summon") as Extract<AbilityEffect, {kind: "summon"}>;
return useCombatStore.getState().summonActor(owner, source, { ...effect, creatureId }, position, null, 1_000)!;
}
describe("Death Pact source semantics and learned Blood Gorged", () => {
beforeEach(() => {
restoreClock = installCombatClock(() => 1_000);
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80 });
usePartyStore.setState({ members: [], command: "attack" });
useCombatStore.setState({ health: 200, maxHealth: 1_000, resource: 100,
resourcePools: { "runic-power": 100 }, gearStats: { ...EMPTY_ITEM_STATS, criticalStrikeRating: 100_000 } });
useCombatStore.getState().synchronizeActors();
});
afterEach(() => restoreClock?.());
it("does not spend resources, cooldown or GCD without a living owned minion", () => {
hostile("trash"); hostile("boss", true);
const result = useCombatStore.getState().castAbility(PACT, undefined, 2_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met", damage: 0, healing: 0 });
const s = useCombatStore.getState();
expect(s.resource).toBe(100); expect(s.cooldowns[PACT]).toBeUndefined();
expect(s.globalCooldownEndsAt).toBe(0);
expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000);
});
it("consumes one actual Raise Dead minion, heals only its caster for 40%, and never crits", () => {
hostile("trash"); hostile("boss", true);
expect(useCombatStore.getState().castAbility(RAISE, undefined, 1_000).ok).toBe(true);
const first = useCombatStore.getState().summons.find(s => s.creatureId === 26125)!;
expect(first).toBeDefined();
const other = summon();
const before = useCombatStore.getState();
const result = before.castAbility(PACT, undefined, 3_000);
expect(result).toMatchObject({ ok: true, damage: 0, healing: 400 });
const s = useCombatStore.getState();
expect(s.health).toBe(600); expect(s.resource).toBe(60);
expect(s.cooldowns[PACT]).toBe(123_000); expect(s.globalCooldownEndsAt).toBe(4_500);
expect(s.summons.find(x => x.id === first.id)?.health).toBe(0);
expect(s.actors[first.id]).toMatchObject({ health: 0, alive: false });
expect(s.summons.find(x => x.id === other)?.health).toBeGreaterThan(0);
expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000);
const events = s.combatEvents.filter(e => e.abilityId === PACT);
expect(events.filter(e => e.kind === "damage")).toHaveLength(0);
expect(events.filter(e => e.kind === "death")).toHaveLength(1);
expect(events.filter(e => e.kind === "healing")).toEqual([expect.objectContaining({
targetActorId: PLAYER_AGGRO_ID, rawAmount: 400, effectiveAmount: 400, critical: false,
})]);
});
it.each(["dead", "expired", "distant", "living-family"])("rejects %s minions and does not heal", mode => {
const id = summon(PLAYER_AGGRO_ID, mode === "distant" ? [101, 0, 0] : [0, 0, 0], mode === "living-family" ? 29264 : 26125);
useCombatStore.setState(s => ({ summons: s.summons.map(x => x.id === id
? { ...x, ...(mode === "dead" ? {health: 0} : {}), ...(mode === "expired" ? {expiresAt: 2_000} : {}) } : x) }));
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().health).toBe(200);
});
it("does not consume another caster's minion; companion automatic use heals only itself", () => {
const base = createConfiguredPartyMember({ name: "DK", classId: "death-knight", specialization: "Blood",
specializationId: "death-knight:blood", talentTreeId: "death-knight-blood", role: "damage" }, 80, 0, "pact-party");
const member = { ...base, health: 100, maxHealth: 1_000, resource: 100,
resourcePools: {"runic-power": 100}, gearStats: EMPTY_ITEM_STATS, nextActionAt: 0,
talentRanks: {}, cooldowns: Object.fromEntries(abilitiesForCharacter("death-knight", null)
.filter(a => a.id !== PACT).map(a => [a.id, 20_000])) };
usePartyStore.setState({ members: [member], command: "attack" });
registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
const id = summon(member.id);
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_000)).toBe(true);
hostile("party-trash");
useCombatStore.setState(s => ({ mobs: { ...s.mobs, "party-trash": { ...s.mobs["party-trash"], engaged: true } } }));
advanceDungeonPartyCombat(2_000);
const s = useCombatStore.getState();
expect(s.summons.find(x => x.id === id)?.health).toBe(0);
expect(s.health).toBe(200);
expect(usePartyStore.getState().members[0].health).toBe(500);
expect(s.combatEvents.filter(e => e.kind === "healing" && e.abilityId === PACT)).toEqual([
expect.objectContaining({ sourceActorId: member.id, targetActorId: member.id, rawAmount: 400, critical: false }),
]);
expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_001)).toBe(false);
});
it("caps overhealing and accounts for it once", () => {
summon(); useCombatStore.setState({ health: 950 });
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000).healing).toBe(50);
expect(useCombatStore.getState().combatEvents.find(e => e.kind === "healing" && e.abilityId === PACT))
.toMatchObject({ rawAmount: 400, effectiveAmount: 50, overheal: 350, critical: false });
});
it.each([1, 2, 3, 4, 5])("keeps Blood Gorged rank %s passive without an invented direct attack", rank => {
const source = ability("wow335-death-knight-blood-gorged");
expect(source.passive).toBe(true);
expect(source.ranks?.[rank - 1].effects.some(e => ["damage", "heal", "execute"].includes(e.kind))).toBe(false);
useCombatStore.setState({ talentRanks: { "talent-2034": rank } });
expect(useCombatStore.getState().castAbility(source.id, undefined, 2_000))
.toMatchObject({ ok: false, reason: "passive", damage: 0 });
});
});