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,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 });
});
});