214 lines
9.3 KiB
TypeScript
214 lines
9.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
|
import { PLAYER_AURA_ENTITY_ID, useCombatStore, type CombatSummon } from "./combatStore";
|
|
import { setMobPosition } from "./mobRuntimeRegistry";
|
|
import { applyAura } from "./combatAuras";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
|
import {
|
|
warlockSoulLinkDamageSplit,
|
|
warlockSoulLinkDemon,
|
|
warlockSoulLinkSharePercent,
|
|
type WarlockSoulLinkSummon,
|
|
} from "./wow335WarlockSoulLinkRuntime";
|
|
|
|
const demon = (overrides: Partial<WarlockSoulLinkSummon> = {}): WarlockSoulLinkSummon => ({
|
|
sourceAbilityId: "wow335-warlock-summon-felhunter",
|
|
ownerActorId: "warlock-player",
|
|
health: 300,
|
|
expiresAt: 10_000,
|
|
controlledUntil: 0,
|
|
position: [300, 0, 0],
|
|
...overrides,
|
|
});
|
|
|
|
function combatDemon(overrides: Partial<CombatSummon> = {}): CombatSummon {
|
|
return {
|
|
id: "soul-link-felhunter",
|
|
sourceAbilityId: "wow335-warlock-summon-felhunter",
|
|
creatureId: 417,
|
|
targetId: null,
|
|
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
kind: "pet",
|
|
name: "Felhunter",
|
|
ownerActorId: PLAYER_AURA_ENTITY_ID,
|
|
health: 500,
|
|
maxHealth: 500,
|
|
position: [10, 0, 0],
|
|
nextActionAt: 0,
|
|
pulseIntervalMs: 0,
|
|
attackDamage: 0,
|
|
abilityIds: [],
|
|
resources: {},
|
|
cooldowns: {},
|
|
globalCooldownEndsAt: 0,
|
|
cast: null,
|
|
controlledUntil: 0,
|
|
schoolLockedUntil: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("WoW Warlock Soul Link mechanics", () => {
|
|
it("uses the populated generated talent tooltip and exposes Soul Link as an active self buff", () => {
|
|
const ability = WOW335_ABILITIES_BY_CLASS.warlock.find((candidate) => candidate.name === "Soul Link");
|
|
expect(ability?.target).toBe("self");
|
|
expect(ability?.description).toContain("6% of all damage taken by the caster");
|
|
expect(ability?.description).toContain("Cannot Soul Link a Summoned Imp");
|
|
expect(ability?.effects).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
aura: expect.objectContaining({
|
|
utilityTags: ["wow335-warlock-soul-link"],
|
|
durationMs: null,
|
|
}),
|
|
}),
|
|
]));
|
|
});
|
|
|
|
it("uses the generated Soul Link and Demonic Embrace rank descriptions", () => {
|
|
expect([0, 1, 2, 3].map((rank) => warlockSoulLinkSharePercent(1, rank)))
|
|
.toEqual([6, 8, 10, 12]);
|
|
expect(warlockSoulLinkSharePercent(0, 3)).toBe(0);
|
|
});
|
|
|
|
it("requires the caster's live, controlled, non-Imp demon within 30 yards", () => {
|
|
const linked = warlockSoulLinkDemon([demon()], "warlock-player", [0, 0, 0], 1_000);
|
|
expect(linked).not.toBeNull();
|
|
expect(warlockSoulLinkDemon(
|
|
[demon({ sourceAbilityId: "wow335-warlock-summon-imp" })],
|
|
"warlock-player",
|
|
[0, 0, 0],
|
|
1_000,
|
|
)).toBeNull();
|
|
expect(warlockSoulLinkDemon([demon({ ownerActorId: "another-warlock" })], "warlock-player", [0, 0, 0], 1_000))
|
|
.toBeNull();
|
|
expect(warlockSoulLinkDemon([demon({ health: 0 })], "warlock-player", [0, 0, 0], 1_000)).toBeNull();
|
|
expect(warlockSoulLinkDemon([demon({ expiresAt: 1_000 })], "warlock-player", [0, 0, 0], 1_000)).toBeNull();
|
|
expect(warlockSoulLinkDemon([demon({ controlledUntil: 1_001 })], "warlock-player", [0, 0, 0], 1_000))
|
|
.toBeNull();
|
|
expect(warlockSoulLinkDemon([demon({ position: [301, 0, 0] })], "warlock-player", [0, 0, 0], 1_000))
|
|
.toBeNull();
|
|
});
|
|
|
|
it("splits post-mitigation damage and rounds the unpreventable demon share", () => {
|
|
expect(warlockSoulLinkDamageSplit(101, 6)).toEqual({ casterDamage: 95, demonDamage: 6 });
|
|
expect(warlockSoulLinkDamageSplit(5, 12)).toEqual({ casterDamage: 4, demonDamage: 1 });
|
|
expect(warlockSoulLinkDamageSplit(0, 12)).toEqual({ casterDamage: 0, demonDamage: 0 });
|
|
});
|
|
|
|
it("transfers the generated Soul Link and Demonic Embrace share during incoming damage", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 });
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: { ...state.talentRanks, "talent-1282": 1, "talent-1223": 3 },
|
|
summons: [combatDemon()],
|
|
playerPosition: [0, 0, 0],
|
|
}));
|
|
const soulLink = useCombatStore.getState().abilities.find((ability) => ability.name === "Soul Link");
|
|
expect(soulLink).toBeDefined();
|
|
expect(useCombatStore.getState().castAbility(soulLink!.id, undefined, 1_000).ok).toBe(true);
|
|
|
|
const demonBefore = useCombatStore.getState().summons[0]!.health;
|
|
const playerDamage = useCombatStore.getState().damagePlayer(100, "shadow", 80, 2_000);
|
|
const state = useCombatStore.getState();
|
|
const playerHit = [...state.combatEvents].reverse().find((event) => (
|
|
event.kind === "damage" && event.targetActorId === PLAYER_AURA_ENTITY_ID && event.occurredAt === 2_000
|
|
));
|
|
const demonHit = [...state.combatEvents].reverse().find((event) => (
|
|
event.kind === "damage" && event.targetActorId === "soul-link-felhunter" && event.occurredAt === 2_000
|
|
));
|
|
expect(playerHit).toBeDefined();
|
|
expect(demonHit).toMatchObject({ abilityId: "wow335-warlock-soul-link", absorbed: 0 });
|
|
const expectedShare = Math.round(playerHit!.rawAmount * 0.12);
|
|
expect(demonHit!.effectiveAmount).toBe(expectedShare);
|
|
expect(state.summons[0]!.health).toBe(demonBefore - expectedShare);
|
|
expect(playerDamage).toBe(playerHit!.rawAmount - expectedShare);
|
|
});
|
|
|
|
it("shares enemy melee damage with an active demon during combat", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 });
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: { ...state.talentRanks, "talent-1282": 1 },
|
|
summons: [combatDemon()],
|
|
playerPosition: [0, 0, 0],
|
|
}));
|
|
const soulLink = useCombatStore.getState().abilities.find((ability) => ability.name === "Soul Link");
|
|
expect(useCombatStore.getState().castAbility(soulLink!.id, undefined, 1_000).ok).toBe(true);
|
|
useCombatStore.getState().registerMob("soul-link-attacker", { name: "Attacker", maxHealth: 10_000, xpReward: 0 });
|
|
setMobPosition("soul-link-attacker", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("soul-link-attacker", 1, 1_000);
|
|
|
|
expect(useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0])).toBeGreaterThan(0);
|
|
const state = useCombatStore.getState();
|
|
const demonHit = [...state.combatEvents].reverse().find((event) => (
|
|
event.kind === "damage" && event.targetActorId === "soul-link-felhunter" && event.occurredAt === 1_450
|
|
));
|
|
expect(demonHit).toMatchObject({ abilityId: "wow335-warlock-soul-link", absorbed: 0 });
|
|
expect(demonHit!.effectiveAmount).toBeGreaterThan(0);
|
|
expect(state.summons[0]!.health).toBe(500 - demonHit!.effectiveAmount);
|
|
});
|
|
|
|
it("shares enemy damage with a companion Warlock's linked demon", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 });
|
|
usePartyStore.getState().initializeParty("soul-link-companion-test", 80, useCombatStore.getState().health);
|
|
const member = usePartyStore.getState().members[0]!;
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((candidate) => candidate.id === member.id
|
|
? { ...candidate, classId: "warlock", talentRanks: { ...candidate.talentRanks, "talent-1282": 1 } }
|
|
: candidate),
|
|
}));
|
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
const linkAbility = WOW335_ABILITIES_BY_CLASS.warlock.find((ability) => ability.name === "Soul Link")!;
|
|
const linkAura = linkAbility.effects.find((effect) => effect.kind === "apply-aura")!;
|
|
if (linkAura.kind !== "apply-aura") throw new Error("Soul Link must apply its caster aura");
|
|
const companionDemon = combatDemon({
|
|
id: "companion-soul-link-felhunter",
|
|
ownerActorId: member.id,
|
|
position: [10, 0, 0],
|
|
});
|
|
useCombatStore.setState((state) => ({
|
|
playerPosition: [20, 0, 0],
|
|
summons: [companionDemon],
|
|
auras: [...applyAura(state.auras, {
|
|
definition: linkAura.aura,
|
|
sourceId: member.id,
|
|
targetId: member.id,
|
|
now: 1_000,
|
|
}).auras],
|
|
}));
|
|
useCombatStore.getState().registerMob("companion-soul-link-attacker", {
|
|
name: "Attacker",
|
|
maxHealth: 10_000,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition("companion-soul-link-attacker", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob(
|
|
"companion-soul-link-attacker",
|
|
1,
|
|
1_000,
|
|
{ actorId: member.id, role: "tank" },
|
|
);
|
|
|
|
let combatDamage = 0;
|
|
for (let now = 3_350; now < 20_000; now += 1_900) {
|
|
combatDamage += useCombatStore.getState().advanceMobCombat(now, [20, 0, 0]);
|
|
if (useCombatStore.getState().combatEvents.some((event) => (
|
|
event.kind === "damage"
|
|
&& event.targetActorId === "companion-soul-link-felhunter"
|
|
&& event.occurredAt === now
|
|
))) break;
|
|
}
|
|
expect(combatDamage).toBeGreaterThan(0);
|
|
const state = useCombatStore.getState();
|
|
const demonHit = [...state.combatEvents].reverse().find((event) => (
|
|
event.kind === "damage"
|
|
&& event.targetActorId === "companion-soul-link-felhunter"
|
|
&& event.occurredAt >= 3_350
|
|
));
|
|
expect(demonHit).toMatchObject({ abilityId: "wow335-warlock-soul-link", absorbed: 0 });
|
|
expect(demonHit!.effectiveAmount).toBeGreaterThan(0);
|
|
expect(state.summons[0]!.health).toBe(500 - demonHit!.effectiveAmount);
|
|
});
|
|
});
|