From 3687c423e2b8ef5fbffd3834a135f87d236e5de0 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 15 Oct 2026 09:09:45 -0400 Subject: [PATCH] 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. --- ...MBAT_FINDING_19_DEATH_PACT_BLOOD_GORGED.md | 37 ++++++ scripts/generate-wow335-abilities.mjs | 6 +- src/game/abilityCatalog.ts | 1 + src/game/abilityRules.ts | 2 +- src/game/combatRotation.ts | 6 + src/game/combatStore.test.ts | 2 +- src/game/combatStore.ts | 55 +++++++- src/game/partyRuntime.ts | 18 ++- src/game/sacrificeMinion.ts | 18 +++ src/game/wow335AbilityCatalog.ts | 24 +++- src/game/wow335DeathPact.integration.test.ts | 125 ++++++++++++++++++ 11 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 docs/COMBAT_FINDING_19_DEATH_PACT_BLOOD_GORGED.md create mode 100644 src/game/sacrificeMinion.ts create mode 100644 src/game/wow335DeathPact.integration.test.ts diff --git a/docs/COMBAT_FINDING_19_DEATH_PACT_BLOOD_GORGED.md b/docs/COMBAT_FINDING_19_DEATH_PACT_BLOOD_GORGED.md new file mode 100644 index 00000000..685782e8 --- /dev/null +++ b/docs/COMBAT_FINDING_19_DEATH_PACT_BLOOD_GORGED.md @@ -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. diff --git a/scripts/generate-wow335-abilities.mjs b/scripts/generate-wow335-abilities.mjs index cf9d7ea3..81158b9d 100644 --- a/scripts/generate-wow335-abilities.mjs +++ b/scripts/generate-wow335-abilities.mjs @@ -396,7 +396,11 @@ try { // 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 // 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.recoveryTimeMs === 0 && spell.categoryRecoveryTimeMs === 0 diff --git a/src/game/abilityCatalog.ts b/src/game/abilityCatalog.ts index e1805b17..b0518dbc 100644 --- a/src/game/abilityCatalog.ts +++ b/src/game/abilityCatalog.ts @@ -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: "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: "sacrifice-minion"; readonly creatureIds: readonly number[]; readonly radius: number; readonly percentMaxHealth: number } | { readonly kind: "summon"; readonly creatureId: number; diff --git a/src/game/abilityRules.ts b/src/game/abilityRules.ts index 1b3a8252..cd5a22f6 100644 --- a/src/game/abilityRules.ts +++ b/src/game/abilityRules.ts @@ -96,7 +96,7 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints { for (const effect of ability.effects) { if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage"); 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 === "taunt") categories.add("taunt"); if (effect.kind === "interrupt") categories.add("interrupt"); diff --git a/src/game/combatRotation.ts b/src/game/combatRotation.ts index 1e2cad55..06d9f93c 100644 --- a/src/game/combatRotation.ts +++ b/src/game/combatRotation.ts @@ -679,6 +679,12 @@ function scoreAbility( score += injured.length ? -3_000 : 300; } } 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")) { const defensiveThreshold = cooldownPolicy.generic.defensiveHealthPercent / 100; const emergencyThreshold = cooldownPolicy.generic.emergencyHealthPercent / 100; diff --git a/src/game/combatStore.test.ts b/src/game/combatStore.test.ts index 0a81bd4e..49f95fac 100644 --- a/src/game/combatStore.test.ts +++ b/src/game/combatStore.test.ts @@ -1536,7 +1536,7 @@ describe("combat store", () => { 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.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 }); }); diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 05c2b450..6f5eaf28 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -1,3 +1,4 @@ +import { eligibleSacrificeMinion } from "./sacrificeMinion"; import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery"; import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount"; 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; dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number; healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution; + sacrificeActorMinion: (ownerActorId: string, abilityId: string, effect: Extract, origin: CombatPosition, now?: number) => boolean; /** Applies the CoA Holy Candle self-damage trigger to a companion or channel caster. */ triggerRomPriestHolyCandle: (ownerActorId: string, healingAbilityId: 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 combatEventSequence = 0; +function sacrificeMinionInWork( + work: Pick, + ownerActorId: string, + abilityId: string, + effect: Extract, + 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 { @@ -1908,7 +1934,7 @@ function recordCombatEvent( return appendCombatEvent(events, input, combatEventSequence); } -function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput): void { +function recordCombatEventInWork(work: Pick, input: CombatEventInput): void { work.combatEvents = [...recordCombatEvent(work.combatEvents, input)]; } @@ -13665,6 +13691,8 @@ export const useCombatStore = create((set, get) => ({ && Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met"; else if (ability.name === "Enraged Regeneration" && !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" && !state.summons.some((summon) => summon.health <= 0 && (summon.id.startsWith("player-pet-") @@ -16879,6 +16907,22 @@ export const useCombatStore = create((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") { for (const id of affectedMobIds) { const mob = work.mobs[id]; @@ -18676,6 +18720,15 @@ export const useCombatStore = create((set, get) => ({ 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) => { const now = request.now ?? combatNow(); const state = get(); diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts index e6cdc3b4..fe5c4a00 100644 --- a/src/game/partyRuntime.ts +++ b/src/game/partyRuntime.ts @@ -1,3 +1,4 @@ +import { eligibleSacrificeMinion } from "./sacrificeMinion"; import { healerManaRecoveryAmount } from "./healerManaRecovery"; import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount"; import { romNativePlayerHitProfile } from "./romNativeCombatResolution"; @@ -2666,6 +2667,8 @@ export function partyAbilityTargetIsValid( || (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false; } 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( member.classId, member.secondaryClassId, @@ -3190,7 +3193,7 @@ function choosePartySupportIntent( lowestHealthFraction: target.health / Math.max(1, target.maxHealth), friendlyTargets, 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))) && (healingAbilityCanAidOthers(ability) || target.id === member.id) && !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects) @@ -4587,6 +4590,17 @@ function executePartyAbility( ability.id, 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") { for (const hostileTargetId of hostileTargets) { const target = useCombatStore.getState().mobs[hostileTargetId]; @@ -5147,7 +5161,7 @@ function executePartyAbility( } 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))) { + 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); } if (suddenDoomTrigger && depth === 0) { diff --git a/src/game/sacrificeMinion.ts b/src/game/sacrificeMinion.ts new file mode 100644 index 00000000..d4780365 --- /dev/null +++ b/src/game/sacrificeMinion.ts @@ -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, + 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); +} diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts index 848cb5ba..2833e637 100644 --- a/src/game/wow335AbilityCatalog.ts +++ b/src/game/wow335AbilityCatalog.ts @@ -1633,6 +1633,14 @@ function classScriptEffects( triggerSpellId: 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:Army of the Dead": return Array.from({ length: 8 }, () => ({ 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[] { const effects: AbilityEffect[] = []; 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" && ["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") && !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) { 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) => !( (classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection") + || (classId === "death-knight" && abilityName === "Death Pact" && ["execute", "heal"].includes(effect.kind)) || (classId === "paladin" && abilityName === "Divine Storm" && (effect.kind === "damage" || effect.kind === "heal")) || (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" ? Math.max(1, firstRaw.sourceLevel || firstRaw.level) : chain.sourceUnlockLevel; - const target = abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects); - const radius = Math.max( + const target = first.effects.some((effect) => effect.kind === "sacrifice-minion") + ? "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), ...firstRaw.effects.map((effect) => effect.radius), ); @@ -2579,7 +2594,8 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef gcdMs: first.gcdMs, cost: first.cost, 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", // Some stock WotLK active talents remain in Spell.dbc after Ascension's // patched Talent.dbc removes their nodes (Devastate, Shadowstep, Lava diff --git a/src/game/wow335DeathPact.integration.test.ts b/src/game/wow335DeathPact.integration.test.ts new file mode 100644 index 00000000..2e6603a1 --- /dev/null +++ b/src/game/wow335DeathPact.integration.test.ts @@ -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; + 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 }); + }); +});