Update Healer Man server
This commit is contained in:
1 parent
9d7e0e2f9e
commit
35dfdaed61
94 files changed
+17034
-418
No files matched your search
@@ -0,0 +1,379 @@
|
||||
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 { 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]);
|
||||
}
|
||||
|
||||
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("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);
|
||||
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().auras.find((candidate) => candidate.instanceId === aura!.instanceId)
|
||||
?.absorbs[0].remaining).toBe(Math.round(useCombatStore.getState().maxHealth * 0.5) - 75);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user