Files
healer-man/src/game/deathKnightSourceRuntime.test.ts
T
2026-10-02 10:38:12 -04:00

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