550 lines
23 KiB
TypeScript
550 lines
23 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
|
import { installCombatClock } from "./combatClock";
|
|
import { createDeathKnightRunes } from "./combatActors";
|
|
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
|
|
import { applyAura as applyCombatAura } from "./combatAuras";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { talentNodesForTree } from "./talentCatalog";
|
|
|
|
let now = 1_000;
|
|
let restoreClock: (() => void) | undefined;
|
|
|
|
function ability(name: string) {
|
|
const found = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
|
if (!found) throw new Error("Missing Death Knight ability: " + name);
|
|
return found;
|
|
}
|
|
|
|
function learnActiveAbility(name: string) {
|
|
const found = ability(name);
|
|
if (found.talentNodeId) {
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: { ...state.talentRanks, [found.talentNodeId!]: 1 },
|
|
}));
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function registerTarget(id = "dk-target", position: readonly [number, number, number] = [2, 0, 0]) {
|
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000, xpReward: 0 });
|
|
setMobPosition(id, position);
|
|
useCombatStore.getState().selectMob(id);
|
|
}
|
|
|
|
function selectPartyMember(index = 0, raceId: "undead" | "human" = "human") {
|
|
const initial = usePartyStore.getState().members[index];
|
|
if (!initial) throw new Error("Expected a party member for the Death Knight test.");
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((member) => member.id === initial.id
|
|
? { ...member, raceId, health: 1 }
|
|
: member),
|
|
}));
|
|
registerPartyRuntimePosition(initial.id, [2, 0, 0]);
|
|
usePartyStore.getState().selectMember(initial.id);
|
|
return initial.id;
|
|
}
|
|
|
|
function setButchery(rank: number, runicPower = 0) {
|
|
const node = talentNodesForTree("death-knight-blood").find((candidate) => candidate.name === "Butchery");
|
|
if (!node) throw new Error("Missing Death Knight Butchery talent.");
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: { ...state.talentRanks, [node.id]: rank },
|
|
resource: runicPower,
|
|
resourcePools: { ...state.resourcePools, "runic-power": runicPower },
|
|
}));
|
|
}
|
|
|
|
function registerXpTarget(id: string, xpReward: number) {
|
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1, xpReward });
|
|
setMobPosition(id, [2, 0, 0]);
|
|
}
|
|
|
|
function installAntiMagicShell(targetId: string, maximumHealth: number) {
|
|
const result = applyCombatAura(useCombatStore.getState().auras, {
|
|
definition: {
|
|
id: "wow335:48707:anti-magic-shell",
|
|
name: "Anti-Magic Shell",
|
|
disposition: "buff",
|
|
durationMs: 5_000,
|
|
maxStacks: 1,
|
|
stackBehavior: "replace",
|
|
utilityTags: ["wow335-death-knight-anti-magic-shell"],
|
|
absorbs: [{
|
|
id: "wow335:48707:anti-magic-shell-absorb",
|
|
amount: maximumHealth * 0.5,
|
|
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
|
|
fraction: 0.75,
|
|
}],
|
|
},
|
|
sourceId: targetId,
|
|
targetId,
|
|
now,
|
|
});
|
|
useCombatStore.setState({ auras: [...result.auras] });
|
|
return result.aura;
|
|
}
|
|
|
|
describe("Death Knight source-backed runtime mechanics", () => {
|
|
beforeEach(() => {
|
|
now = 1_000;
|
|
restoreClock = installCombatClock(() => now);
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
|
|
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
|
|
usePartyStore.getState().initializeParty("death-knight-source-runtime", 80,
|
|
useCombatStore.getState().health, "tank", 5);
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreClock?.();
|
|
restoreClock = undefined;
|
|
});
|
|
|
|
it("heals a friendly Undead with Death Coil and rejects a living ally", () => {
|
|
const allyId = selectPartyMember(0, "human");
|
|
const deathCoil = ability("Death Coil");
|
|
const rejected = useCombatStore.getState().castAbility(deathCoil.id, {
|
|
targetActorId: allyId,
|
|
now,
|
|
});
|
|
expect(rejected).toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((member) => member.id === allyId
|
|
? { ...member, raceId: "undead" }
|
|
: member),
|
|
}));
|
|
now = 3_000;
|
|
const healed = useCombatStore.getState().castAbility(deathCoil.id, {
|
|
targetActorId: allyId,
|
|
now,
|
|
});
|
|
expect(healed.ok).toBe(true);
|
|
expect(healed.healing).toBeGreaterThan(0);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === allyId)!.health).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("uses Death Coil's damaging branch against a hostile living target", () => {
|
|
registerTarget();
|
|
const deathCoil = ability("Death Coil");
|
|
const attempts = [1_000, 3_000, 5_000].map((castAt) => {
|
|
now = castAt;
|
|
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
|
|
return useCombatStore.getState().castAbility(deathCoil.id, { now });
|
|
});
|
|
expect(attempts.every((result) => result.ok)).toBe(true);
|
|
expect(attempts.some((result) => result.damage > 0)).toBe(true);
|
|
expect(attempts.every((result) => result.healing === 0)).toBe(true);
|
|
});
|
|
|
|
it("extends Blood Plague and Frost Fever by Epidemic's generated duration", () => {
|
|
registerTarget();
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: { ...state.talentRanks, "talent-1963": 2 },
|
|
}));
|
|
const icyTouch = ability("Icy Touch");
|
|
expect(useCombatStore.getState().castAbility(icyTouch.id, { targetActorId: "dk-target", now }).ok).toBe(true);
|
|
expect(useCombatStore.getState().effects.find((effect) => (
|
|
effect.kind === "dot" && effect.tag === "frost-fever" && effect.targetId === "dk-target"
|
|
))).toMatchObject({ remainingTicks: 7, intervalMs: 3_000 });
|
|
});
|
|
|
|
it("absorbs three quarters of magical damage up to half of maximum health", () => {
|
|
const shell = ability("Anti-Magic Shell");
|
|
expect(useCombatStore.getState().castAbility(shell.id, { now }).ok).toBe(true);
|
|
useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } });
|
|
const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& candidate.definition.name === "Anti-Magic Shell");
|
|
expect(aura?.absorbs[0]).toMatchObject({
|
|
remaining: Math.round(useCombatStore.getState().maxHealth * 0.5),
|
|
fraction: 0.75,
|
|
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
|
|
});
|
|
|
|
now += 1;
|
|
expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25);
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(1.5);
|
|
expect(useCombatStore.getState().resource).toBe(1.5);
|
|
expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura!.instanceId)
|
|
?.absorbs[0].remaining).toBe(Math.round(useCombatStore.getState().maxHealth * 0.5) - 75);
|
|
});
|
|
|
|
it("caps AMS absorption at half maximum health before awarding proportional Runic Power", () => {
|
|
useCombatStore.setState({
|
|
health: 1_000,
|
|
maxHealth: 1_000,
|
|
resource: 20,
|
|
resourcePools: { "runic-power": 20 },
|
|
});
|
|
expect(useCombatStore.getState().castAbility(ability("Anti-Magic Shell").id, { now }).ok).toBe(true);
|
|
useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } });
|
|
|
|
expect(useCombatStore.getState().damagePlayer(1_000, "fire", undefined, now + 1)).toBe(500);
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(10);
|
|
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-shell"))?.absorbs[0].remaining).toBe(0);
|
|
});
|
|
|
|
it("grants Runic Power for magic absorbed during the normal enemy-attack path", () => {
|
|
expect(useCombatStore.getState().castAbility(ability("Anti-Magic Shell").id, { now }).ok).toBe(true);
|
|
useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } });
|
|
const shell = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-shell"))!;
|
|
const capacityBefore = shell.absorbs[0].remaining;
|
|
useCombatStore.getState().registerMob("ams-player-attacker", {
|
|
name: "AMS player attacker",
|
|
maxHealth: 1_000,
|
|
xpReward: 0,
|
|
attacks: [{
|
|
id: "ams-player-fire",
|
|
name: "Fire strike",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "fire",
|
|
animation: "attack",
|
|
range: 5,
|
|
cooldownMs: 1_000,
|
|
damageMultiplier: 1,
|
|
}],
|
|
});
|
|
setMobPosition("ams-player-attacker", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("ams-player-attacker", 1, now);
|
|
|
|
useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]);
|
|
const shellAfterHit = useCombatStore.getState().auras.find((aura) => aura.instanceId === shell.instanceId);
|
|
const absorbed = capacityBefore - (shellAfterHit?.absorbs[0].remaining ?? 0);
|
|
expect(absorbed).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBeCloseTo(absorbed * 0.02);
|
|
});
|
|
|
|
it("clamps AMS Runic Power gain and ignores physical, missing, and expired shells", () => {
|
|
expect(useCombatStore.getState().castAbility(ability("Anti-Magic Shell").id, { now }).ok).toBe(true);
|
|
useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } });
|
|
useCombatStore.getState().damagePlayer(100, "physical", undefined, now + 1);
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
|
|
|
|
useCombatStore.setState({ auras: [] });
|
|
useCombatStore.getState().damagePlayer(100, "fire", undefined, now + 2);
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
|
|
|
|
const shell = installAntiMagicShell(PLAYER_AURA_ENTITY_ID, useCombatStore.getState().maxHealth);
|
|
useCombatStore.setState({
|
|
resource: 99,
|
|
resourcePools: { "runic-power": 99 },
|
|
auras: useCombatStore.getState().auras.map((aura) => aura.instanceId === shell.instanceId
|
|
? { ...aura, expiresAt: now + 3 }
|
|
: aura),
|
|
});
|
|
useCombatStore.getState().damagePlayer(100, "fire", undefined, now + 4);
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(99);
|
|
|
|
useCombatStore.setState({
|
|
resource: 99,
|
|
resourcePools: { "runic-power": 99 },
|
|
auras: [shell],
|
|
});
|
|
useCombatStore.getState().damagePlayer(100, "fire", undefined, now + 1);
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(100);
|
|
});
|
|
|
|
it("grants a Death Knight companion Runic Power from its own live AMS absorb", () => {
|
|
const target = usePartyStore.getState().members[0];
|
|
if (!target) throw new Error("Expected a party member for the companion AMS test.");
|
|
const companion = {
|
|
...target,
|
|
classId: "death-knight" as const,
|
|
resourceType: "runic-power" as const,
|
|
resource: 0,
|
|
maxResource: 100,
|
|
resourcePools: { ...target.resourcePools, "runic-power": 0 },
|
|
};
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((member) => member.id === target.id ? companion : member),
|
|
}));
|
|
useCombatStore.setState((state) => {
|
|
const actors = { ...state.actors };
|
|
delete actors[target.id];
|
|
return { ...state, actors };
|
|
});
|
|
registerPartyRuntimePosition(target.id, [0, 0, 0]);
|
|
const aura = installAntiMagicShell(target.id, companion.maxHealth);
|
|
useCombatStore.getState().registerMob("ams-companion-attacker", {
|
|
name: "AMS companion attacker",
|
|
maxHealth: 1_000,
|
|
xpReward: 0,
|
|
attacks: [{
|
|
id: "ams-companion-fire",
|
|
name: "Fire strike",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "fire",
|
|
animation: "attack",
|
|
range: 5,
|
|
cooldownMs: 1_000,
|
|
damageMultiplier: 1,
|
|
}],
|
|
});
|
|
setMobPosition("ams-companion-attacker", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("ams-companion-attacker", 1, now, {
|
|
actorId: target.id,
|
|
role: target.role,
|
|
});
|
|
|
|
useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]);
|
|
const updated = usePartyStore.getState().members.find((member) => member.id === target.id)!;
|
|
const shellAfterHit = useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura.instanceId);
|
|
const absorbed = companion.maxHealth * 0.5 - (shellAfterHit?.absorbs[0].remaining ?? 0);
|
|
expect(absorbed).toBeGreaterThan(0);
|
|
expect(updated.resourcePools["runic-power"]).toBeCloseTo(absorbed * 0.02);
|
|
});
|
|
|
|
it("blocks harmful magic controls during Anti-Magic Shell while allowing physical controls", () => {
|
|
const shell = ability("Anti-Magic Shell");
|
|
expect(useCombatStore.getState().castAbility(shell.id, { now }).ok).toBe(true);
|
|
const addControlAttacker = (id: string, school: "shadow" | "physical") => {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: id,
|
|
maxHealth: 1_000,
|
|
xpReward: 0,
|
|
attacks: [{
|
|
id: `${id}-stun`,
|
|
name: "Stunning attack",
|
|
delivery: "projectile",
|
|
target: "primary",
|
|
school,
|
|
animation: "attack",
|
|
range: 30,
|
|
cooldownMs: 1_000,
|
|
damageMultiplier: 0,
|
|
effect: { kind: "control", mechanic: "stun", durationMs: 5_000 },
|
|
}],
|
|
});
|
|
setMobPosition(id, [2, 0, 0]);
|
|
useCombatStore.getState().damageMob(id, 1, now);
|
|
return useCombatStore.getState().mobs[id].nextAttackAt;
|
|
};
|
|
|
|
const magicReadyAt = addControlAttacker("magic-stun", "shadow");
|
|
useCombatStore.getState().advanceMobCombat(magicReadyAt, [0, 0, 0]);
|
|
expect(useCombatStore.getState().mobs["magic-stun"].attackRevision).toBe(1);
|
|
expect(useCombatStore.getState().controlledUntil).toBe(0);
|
|
|
|
const physicalReadyAt = addControlAttacker("physical-stun", "physical");
|
|
useCombatStore.getState().advanceMobCombat(physicalReadyAt, [0, 0, 0]);
|
|
expect(useCombatStore.getState().mobs["physical-stun"].attackRevision).toBe(1);
|
|
expect(useCombatStore.getState().controlledUntil).toBeGreaterThan(physicalReadyAt);
|
|
});
|
|
|
|
it("places Anti-Magic Zone at the selected point and protects party members within 10 yards", () => {
|
|
const inRangeAlly = selectPartyMember(0, "human");
|
|
const outOfRangeAlly = usePartyStore.getState().members[1].id;
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((member) => ({ ...member, health: 1 })),
|
|
}));
|
|
registerPartyRuntimePosition(inRangeAlly, [5, 0, 0]);
|
|
registerPartyRuntimePosition(outOfRangeAlly, [11, 0, 0]);
|
|
const antiMagicZone = learnActiveAbility("Anti-Magic Zone");
|
|
const cast = useCombatStore.getState().castAbility(antiMagicZone.id, {
|
|
now,
|
|
groundPosition: [0, 0, 0],
|
|
});
|
|
|
|
expect(cast.ok).toBe(true);
|
|
expect(useCombatStore.getState().summons).toContainEqual(expect.objectContaining({
|
|
creatureId: 28306,
|
|
position: [0, 0, 0],
|
|
expiresAt: now + 10_000,
|
|
antiMagicZone: expect.objectContaining({ radius: 10, absorbFraction: 0.75 }),
|
|
}));
|
|
const partyAuras = useCombatStore.getState().auras.filter((aura) => (
|
|
aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone")
|
|
));
|
|
expect(partyAuras.map((aura) => aura.targetId)).toEqual(expect.arrayContaining([
|
|
PLAYER_AURA_ENTITY_ID,
|
|
inRangeAlly,
|
|
]));
|
|
expect(partyAuras.map((aura) => aura.targetId)).not.toContain(outOfRangeAlly);
|
|
expect(partyAuras.find((aura) => aura.targetId === inRangeAlly)?.absorbs[0]).toMatchObject({
|
|
remaining: expect.any(Number),
|
|
fraction: 0.75,
|
|
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
|
|
});
|
|
expect(partyAuras.find((aura) => aura.targetId === inRangeAlly)!.absorbs[0].remaining).toBeGreaterThan(10_000);
|
|
|
|
expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25);
|
|
expect(useCombatStore.getState().damagePlayer(100, "physical")).toBe(100);
|
|
|
|
registerPartyRuntimePosition(inRangeAlly, [11, 0, 0]);
|
|
now += 250;
|
|
useCombatStore.getState().tick(0.25, now);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === inRangeAlly
|
|
&& aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone"))).toBe(false);
|
|
});
|
|
|
|
it("spends Bone Shield's four charges on landed damage while keeping its reduction non-stacking", () => {
|
|
const boneShield = learnActiveAbility("Bone Shield");
|
|
const boneCast = useCombatStore.getState().castAbility(boneShield.id, { now });
|
|
expect(boneCast.reason).toBeUndefined();
|
|
expect(boneCast.ok).toBe(true);
|
|
const auraId = useCombatStore.getState().auras.find((aura) => aura.definition.name === "Bone Shield")!.instanceId;
|
|
|
|
for (const remaining of [3, 2, 1]) {
|
|
now += 1;
|
|
expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(8);
|
|
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === auraId)?.stacks).toBe(remaining);
|
|
}
|
|
now += 1;
|
|
expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(8);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(false);
|
|
now += 1;
|
|
expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(10);
|
|
});
|
|
|
|
it("converts exactly one spent Blood Rune for Blood Tap without activating a Frost Rune", () => {
|
|
const initialRunes = createDeathKnightRunes().map((rune) => (
|
|
rune.slot === 0 || rune.slot === 2 ? { ...rune, readyAt: now + 10_000 } : rune
|
|
));
|
|
useCombatStore.setState({ deathKnightRunes: initialRunes });
|
|
const bloodTap = ability("Blood Tap");
|
|
expect(useCombatStore.getState().castAbility(bloodTap.id, { now }).ok).toBe(true);
|
|
|
|
const runes = useCombatStore.getState().deathKnightRunes;
|
|
expect(runes.filter((rune) => rune.type === "death")).toEqual([
|
|
expect.objectContaining({ slot: 0, baseType: "blood", readyAt: now, deathRuneExpiresAt: now + 20_000 }),
|
|
]);
|
|
expect(runes.find((rune) => rune.slot === 2)).toMatchObject({ type: "frost", readyAt: now + 10_000 });
|
|
expect(runes.filter((rune) => rune.readyAt <= now)).toHaveLength(5);
|
|
});
|
|
|
|
it("consumes Deathchill only on Icy Touch, Howling Blast, Frost Strike, or Obliterate", () => {
|
|
registerTarget();
|
|
const deathchill = learnActiveAbility("Deathchill");
|
|
expect(useCombatStore.getState().castAbility(deathchill.id, { now }).ok).toBe(true);
|
|
const auraId = useCombatStore.getState().auras.find((aura) => (
|
|
aura.definition.utilityTags?.includes("wow335-deathchill-next-critical")
|
|
))!.instanceId;
|
|
|
|
now = 3_000;
|
|
expect(useCombatStore.getState().castAbility(ability("Chains of Ice").id, { now }).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(true);
|
|
|
|
now = 5_000;
|
|
const icyTouch = ability("Icy Touch");
|
|
expect(useCombatStore.getState().castAbility(icyTouch.id, { now }).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(false);
|
|
expect(useCombatStore.getState().combatEvents.some((event) => (
|
|
event.abilityId === icyTouch.id && event.kind === "damage" && event.critical
|
|
))).toBe(true);
|
|
});
|
|
|
|
it("heals Death Strike for five percent of maximum health per active disease", () => {
|
|
registerTarget();
|
|
const icyTouch = ability("Icy Touch");
|
|
const plagueStrike = ability("Plague Strike");
|
|
const deathStrike = ability("Death Strike");
|
|
expect(useCombatStore.getState().castAbility(icyTouch.id, { now }).ok).toBe(true);
|
|
now = 3_000;
|
|
useCombatStore.setState((state) => ({ ...state, health: 1 }));
|
|
const oneDisease = useCombatStore.getState().castAbility(deathStrike.id, { now });
|
|
expect(oneDisease.ok).toBe(true);
|
|
expect(oneDisease.healing).toBeGreaterThan(0);
|
|
|
|
now = 5_000;
|
|
expect(useCombatStore.getState().castAbility(plagueStrike.id, { now }).ok).toBe(true);
|
|
useCombatStore.setState((state) => ({
|
|
...state,
|
|
health: 1,
|
|
deathKnightRunes: createDeathKnightRunes(now),
|
|
}));
|
|
now = 7_000;
|
|
const twoDiseases = useCombatStore.getState().castAbility(deathStrike.id, { now });
|
|
expect(twoDiseases.ok).toBe(true);
|
|
expect(twoDiseases.healing).toBeGreaterThan(oneDisease.healing);
|
|
expect(twoDiseases.healing).toBeCloseTo(oneDisease.healing * 2, -1);
|
|
});
|
|
|
|
it.each([
|
|
{ rank: 1, expectedRunicPower: 10 },
|
|
{ rank: 2, expectedRunicPower: 20 },
|
|
])("grants Butchery rank $rank's runic power on an eligible player kill", ({ rank, expectedRunicPower }) => {
|
|
const targetId = `butchery-rank-${rank}`;
|
|
registerXpTarget(targetId, 1_000);
|
|
setButchery(rank);
|
|
|
|
const result = useCombatStore.getState().damageMob(targetId, 10, now);
|
|
|
|
expect(result.killed).toBe(true);
|
|
expect(useCombatStore.getState().resource).toBe(expectedRunicPower);
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(expectedRunicPower);
|
|
});
|
|
|
|
it("caps Butchery at the runic power maximum", () => {
|
|
const targetId = "butchery-cap-target";
|
|
registerXpTarget(targetId, 1_000);
|
|
setButchery(2, 95);
|
|
|
|
useCombatStore.getState().damageMob(targetId, 10, now);
|
|
|
|
expect(useCombatStore.getState().resource).toBe(100);
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(100);
|
|
});
|
|
|
|
it("does not grant Butchery without the talent or for a target with no XP reward", () => {
|
|
const noTalentTargetId = "butchery-no-talent-target";
|
|
registerXpTarget(noTalentTargetId, 1_000);
|
|
setButchery(0);
|
|
useCombatStore.getState().damageMob(noTalentTargetId, 10, now);
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
|
|
|
|
const noXpTargetId = "butchery-no-xp-target";
|
|
registerXpTarget(noXpTargetId, 0);
|
|
setButchery(2);
|
|
useCombatStore.getState().damageMob(noXpTargetId, 10, now + 1);
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
|
|
});
|
|
|
|
it("does not grant Butchery for a party or pet killing blow", () => {
|
|
setButchery(2);
|
|
const partyActorId = usePartyStore.getState().members[0].id;
|
|
for (const [targetId, actorId] of [
|
|
["butchery-party-target", partyActorId],
|
|
["butchery-pet-target", "butchery-test-pet"],
|
|
] as const) {
|
|
registerXpTarget(targetId, 1_000);
|
|
useCombatStore.getState().damageMob(targetId, 10, now, {
|
|
actorId,
|
|
role: "damage",
|
|
abilityId: "test-non-player-kill",
|
|
});
|
|
}
|
|
|
|
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
|
|
});
|
|
|
|
it("applies Improved Rune Tap's rank-specific healing and cooldown reductions", () => {
|
|
useCombatStore.setState((state) => ({
|
|
...state,
|
|
health: 1,
|
|
talentRanks: { ...state.talentRanks, "talent-1941": 1 },
|
|
}));
|
|
const runeTap = ability("Rune Tap");
|
|
const baseline = useCombatStore.getState().castAbility(runeTap.id, { now });
|
|
expect(baseline.ok).toBe(true);
|
|
now = 3_000;
|
|
useCombatStore.setState((state) => ({
|
|
...state,
|
|
health: 1,
|
|
cooldowns: {},
|
|
deathKnightRunes: createDeathKnightRunes(now),
|
|
talentRanks: { ...state.talentRanks, "talent-1942": 3 },
|
|
}));
|
|
const improved = useCombatStore.getState().castAbility(runeTap.id, { now });
|
|
expect(improved.ok).toBe(true);
|
|
expect(improved.healing).toBeCloseTo(baseline.healing * 2, -1);
|
|
expect(useCombatStore.getState().cooldowns[runeTap.id]).toBe(now + 30_000);
|
|
});
|
|
});
|