790 lines
36 KiB
TypeScript
790 lines
36 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import type { AbilityEffect } from "./abilityCatalog";
|
|
import { abilitiesForCharacter, learnedAbilityForBuild, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
|
import { cooldownResetCandidates } from "./cooldownReset";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "./partyRuntimeRegistry";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
|
import { romEffectsForCast } from "./romCombatMechanics";
|
|
import {
|
|
ROM_WARLOCK_STATE_TAGS,
|
|
} from "./romWarlockOverrides";
|
|
|
|
const TARGET_ID = "rom-warlock-runtime-target";
|
|
|
|
function ability(spellId: number) {
|
|
const result = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId);
|
|
expect(result, `RoM Warlock spell ${spellId}`).toBeDefined();
|
|
return result!;
|
|
}
|
|
|
|
function finishCast(now: number) {
|
|
const cast = useCombatStore.getState().activeCast;
|
|
if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt);
|
|
}
|
|
|
|
function prepareWarlock(secondaryClassId: "rom-warrior" | "rom-rogue" | "rom-priest") {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-warlock",
|
|
secondaryClassId,
|
|
raceId: "rom-human",
|
|
level: 80,
|
|
});
|
|
useCombatStore.setState((state) => ({
|
|
resource: 100,
|
|
maxResource: 100,
|
|
resourcePools: {
|
|
...state.resourcePools,
|
|
focus: 100,
|
|
rage: 0,
|
|
energy: 100,
|
|
psi: 0,
|
|
},
|
|
maxResourcePools: {
|
|
...state.maxResourcePools,
|
|
focus: 100,
|
|
rage: 100,
|
|
energy: 100,
|
|
psi: 6,
|
|
},
|
|
}));
|
|
}
|
|
|
|
function registerTarget() {
|
|
useCombatStore.getState().registerMob(TARGET_ID, { name: "Warlock Test Target", maxHealth: 100_000 });
|
|
setMobPosition(TARGET_ID, [5, 0, 0]);
|
|
}
|
|
|
|
function prepareWarlockPriestSupportCompanion(readySpellId: number, startingPsi: number) {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-dwarf", level: 80 });
|
|
usePartyStore.getState().initializeParty("rom-warlock-priest-psi-companion", 80,
|
|
useCombatStore.getState().health, "healer", 2, "rom");
|
|
const companion = createConfiguredPartyMember({
|
|
name: "Warlock/Priest Psi Companion",
|
|
classId: "rom-warlock",
|
|
secondaryClassId: "rom-priest",
|
|
raceId: "rom-dwarf",
|
|
specialization: "Warlock",
|
|
role: "healer",
|
|
}, 80, 0, "rom-warlock-priest-psi-companion");
|
|
const ally = createConfiguredPartyMember({
|
|
name: "Warlock/Priest Heal Target",
|
|
classId: "rom-warlock",
|
|
secondaryClassId: "rom-priest",
|
|
raceId: "rom-dwarf",
|
|
specialization: "Warlock",
|
|
role: "damage",
|
|
}, 80, 1, "rom-warlock-priest-psi-companion");
|
|
const pairAbilities = abilitiesForCharacter("rom-warlock", "rom-priest");
|
|
const readyAbility = pairAbilities.find((candidate) => candidate.dbcSpellId === readySpellId)!;
|
|
const cooldowns = Object.fromEntries(pairAbilities
|
|
.filter((candidate) => candidate.id !== readyAbility.id)
|
|
.map((candidate) => [candidate.id, 100_000]));
|
|
usePartyStore.setState({
|
|
command: "attack",
|
|
members: [{
|
|
...companion,
|
|
cooldowns,
|
|
resourcePools: { ...companion.resourcePools, focus: 100, psi: startingPsi },
|
|
resource: 100,
|
|
}, ally],
|
|
});
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerPartyRuntimePosition(companion.id, [1, 0, 0]);
|
|
registerPartyRuntimePosition(ally.id, [2, 0, 0]);
|
|
usePartyStore.getState().damageMember(ally.id, 500);
|
|
useCombatStore.getState().damagePlayer(500);
|
|
registerTarget();
|
|
useCombatStore.getState().damageMob(TARGET_ID, 100, 900);
|
|
updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true);
|
|
return { companion, ally, readyAbility };
|
|
}
|
|
|
|
function prepareWarlockPriestFriendlyTarget() {
|
|
usePartyStore.getState().initializeParty("rom-warlock-priest-psi-player", 80,
|
|
useCombatStore.getState().health, "healer", 2, "rom");
|
|
const partyMember = usePartyStore.getState().members[0]!;
|
|
registerPartyRuntimePosition(partyMember.id, [2, 0, 0]);
|
|
usePartyStore.getState().damageMember(partyMember.id, 500);
|
|
useCombatStore.getState().damagePlayer(500);
|
|
usePartyStore.getState().selectMember(partyMember.id);
|
|
return partyMember;
|
|
}
|
|
|
|
function completeWarlockCompanionFriendlyCast(
|
|
companionId: string,
|
|
abilityId: string,
|
|
now: number,
|
|
castTimeMs: number,
|
|
targetId = PLAYER_AURA_ENTITY_ID,
|
|
) {
|
|
const position: [number, number, number] = [1, 0, 0];
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((member) => member.id === companionId ? {
|
|
...member,
|
|
activeCast: {
|
|
abilityId,
|
|
school: "shadow",
|
|
mode: "cast",
|
|
startedAt: now,
|
|
completesAt: now + castTimeMs,
|
|
originalDurationMs: castTimeMs,
|
|
pushbackHits: 0,
|
|
origin: position,
|
|
targetId,
|
|
},
|
|
activeCastTargetId: targetId,
|
|
status: "healing" as const,
|
|
} : member),
|
|
}));
|
|
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
|
|
advanceDungeonPartyCombat(now + castTimeMs);
|
|
}
|
|
|
|
describe("RuneWaker Warlock runtime", () => {
|
|
beforeAll(async () => {
|
|
await preloadAbilityCatalogsForClasses(["rom-warlock"]);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
|
});
|
|
|
|
it("gives the caster Ruthless Judgment's source bonus only below 20% target health", () => {
|
|
const ruthlessJudgment = ability(498544);
|
|
const effectsFor = (targetHealthPercent: number) => romEffectsForCast(ruthlessJudgment, {
|
|
sealStacks: 0,
|
|
bleedCount: 0,
|
|
charged: false,
|
|
mainHandDagger: false,
|
|
casterMaxHealth: 10_000,
|
|
targetHealthPercent,
|
|
});
|
|
const bonusAuras = (targetHealthPercent: number) => effectsFor(targetHealthPercent).filter((effect) => (
|
|
effect.kind === "apply-aura" && effect.sourceAuraType === 192 && effect.miscValue === 5
|
|
));
|
|
const below = bonusAuras(0.199);
|
|
expect(below).toHaveLength(1);
|
|
expect(below[0]).toMatchObject({
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
aura: {
|
|
disposition: "buff",
|
|
durationMs: 20_000,
|
|
modifiers: [expect.objectContaining({
|
|
kind: "damage",
|
|
direction: "dealt",
|
|
attackKind: "spell",
|
|
operation: "percent",
|
|
value: 0.05,
|
|
})],
|
|
},
|
|
});
|
|
expect(bonusAuras(0.2)).toHaveLength(0);
|
|
expect(bonusAuras(0.8)).toHaveLength(0);
|
|
|
|
const sourceDamage = ruthlessJudgment.effects.find((effect) => effect.kind === "damage");
|
|
expect(effectsFor(0.199).find((effect) => effect.kind === "damage")).toEqual(sourceDamage);
|
|
expect(effectsFor(0.2).find((effect) => effect.kind === "damage")).toEqual(sourceDamage);
|
|
});
|
|
|
|
it("applies Ruthless Judgment's low-health bonus to the player caster", () => {
|
|
prepareWarlock("rom-priest");
|
|
registerTarget();
|
|
useCombatStore.getState().damageMob(TARGET_ID, 81_000, 900);
|
|
|
|
const ruthlessJudgment = ability(498544);
|
|
expect(useCombatStore.getState().castAbility(ruthlessJudgment.id, {
|
|
targetActorId: TARGET_ID,
|
|
now: 1_000,
|
|
}).ok).toBe(true);
|
|
const completesAt = useCombatStore.getState().activeCast?.completesAt;
|
|
expect(completesAt).toBeDefined();
|
|
finishCast(1_000);
|
|
|
|
const bonusAura = useCombatStore.getState().auras.find((aura) => (
|
|
aura.sourceId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.name.startsWith("Ruthless Judgment")
|
|
));
|
|
expect(bonusAura).toMatchObject({
|
|
definition: {
|
|
disposition: "buff",
|
|
modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.05 })],
|
|
},
|
|
});
|
|
expect(bonusAura?.expiresAt).toBe(completesAt! + 20_000);
|
|
expect(useCombatStore.getState().auras.some((aura) => (
|
|
aura.targetId === TARGET_ID && aura.definition.name.startsWith("Ruthless Judgment")
|
|
))).toBe(false);
|
|
});
|
|
|
|
it("applies Ruthless Judgment's low-health bonus to a companion Warlock caster", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-dwarf", level: 80 });
|
|
usePartyStore.getState().initializeParty("rom-warlock-ruthless-companion", 80,
|
|
useCombatStore.getState().health, "damage", 2, "rom");
|
|
const warlock = createConfiguredPartyMember({
|
|
name: "Ruthless Companion",
|
|
classId: "rom-warlock",
|
|
secondaryClassId: "rom-priest",
|
|
raceId: "rom-dwarf",
|
|
specialization: "Warlock",
|
|
role: "damage",
|
|
}, 80, 0, "rom-warlock-ruthless-companion");
|
|
const ruthlessJudgment = ability(498544);
|
|
const pairAbilities = abilitiesForCharacter("rom-warlock", "rom-priest");
|
|
usePartyStore.setState({
|
|
command: "attack",
|
|
members: [{
|
|
...warlock,
|
|
cooldowns: Object.fromEntries(pairAbilities
|
|
.filter((candidate) => candidate.id !== ruthlessJudgment.id)
|
|
.map((candidate) => [candidate.id, 100_000])),
|
|
resourcePools: { ...warlock.resourcePools, focus: 100 },
|
|
resource: 100,
|
|
}],
|
|
});
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerPartyRuntimePosition(warlock.id, [0, 0, 0]);
|
|
registerTarget();
|
|
useCombatStore.getState().damageMob(TARGET_ID, 81_000, 900);
|
|
updatePartyRuntimeTargetVisibility(warlock.id, TARGET_ID, true);
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
advanceDungeonPartyCombat(3_100);
|
|
|
|
const bonusAura = useCombatStore.getState().auras.find((aura) => (
|
|
aura.sourceId === warlock.id
|
|
&& aura.targetId === warlock.id
|
|
&& aura.definition.name.startsWith("Ruthless Judgment")
|
|
));
|
|
expect(bonusAura).toMatchObject({
|
|
expiresAt: 23_100,
|
|
definition: {
|
|
disposition: "buff",
|
|
modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.05 })],
|
|
},
|
|
});
|
|
expect(useCombatStore.getState().auras.some((aura) => (
|
|
aura.targetId === TARGET_ID && aura.definition.name.startsWith("Ruthless Judgment")
|
|
))).toBe(false);
|
|
});
|
|
|
|
it("converts a fixed 75 Rage cost into six Psi for Mind Transference", () => {
|
|
prepareWarlock("rom-warrior");
|
|
useCombatStore.setState((state) => ({
|
|
resourcePools: { ...state.resourcePools, rage: 100, psi: 1 },
|
|
}));
|
|
|
|
const mindTransference = ability(498642);
|
|
expect(mindTransference.costs).toEqual([{ resource: "rage", amount: 75, percentage: false }]);
|
|
expect(mindTransference.cooldownMs).toBe(1_000);
|
|
expect(mindTransference.effects).toEqual([{ kind: "resource", resource: "psi", amount: 6 }]);
|
|
expect(useCombatStore.getState().castAbility(mindTransference.id, { now: 1_000 }).ok).toBe(true);
|
|
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(25);
|
|
expect(useCombatStore.getState().resourcePools.psi).toBe(6);
|
|
});
|
|
|
|
it("describes current CoA Breathing Control triggers without restoring the RuneWaker cooldown reset", () => {
|
|
const breathingControl = ability(498634);
|
|
expect(breathingControl.description).toContain(
|
|
"Psychic Arrows, Warp Charge, Weakening Weave Curse, Slash, Soul Trauma, Puzzlement, Heart Collection Strike, or Surge of Malice",
|
|
);
|
|
expect(breathingControl.description).toContain("trigger Enraged automatically");
|
|
expect(breathingControl.description).toContain("does not publish the proc chance, trigger timing, or extra Focus amount");
|
|
expect(breathingControl.description).not.toContain("reset Enraged's cooldown");
|
|
expect(breathingControl.effects.some((effect) => effect.kind === "cooldown-reset")).toBe(false);
|
|
});
|
|
|
|
it("grants one capped caster Psi from an accepted Ring of Comfort cast for the exact pair", () => {
|
|
prepareWarlock("rom-priest");
|
|
const ringOfComfort = abilitiesForCharacter("rom-warlock", "rom-priest")
|
|
.find((candidate) => candidate.dbcSpellId === 498641)!;
|
|
const context = {
|
|
sealStacks: 0,
|
|
bleedCount: 0,
|
|
charged: false,
|
|
mainHandDagger: false,
|
|
casterMaxHealth: 10_000,
|
|
targetHealthPercent: 1,
|
|
} as const;
|
|
expect(romEffectsForCast(ringOfComfort, context).filter((effect) => (
|
|
effect.kind === "resource" && effect.resource === "psi"
|
|
))).toEqual([{ kind: "resource", resource: "psi", amount: 1, scope: "caster" }]);
|
|
const otherPair = { ...ringOfComfort, secondaryClassId: "rom-warrior" as const };
|
|
expect(romEffectsForCast(otherPair, context).some((effect) => (
|
|
effect.kind === "resource" && effect.resource === "psi"
|
|
))).toBe(false);
|
|
|
|
const partyMember = prepareWarlockPriestFriendlyTarget();
|
|
useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [ringOfComfort.id]: 2_000 } }));
|
|
expect(useCombatStore.getState().castAbility(ringOfComfort.id, undefined, 1_000).ok).toBe(false);
|
|
expect(useCombatStore.getState().resourcePools.psi).toBe(0);
|
|
useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [ringOfComfort.id]: 0 } }));
|
|
expect(useCombatStore.getState().castAbility(ringOfComfort.id, undefined, 3_000)).toMatchObject({ ok: true });
|
|
const healingEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "healing"
|
|
&& event.abilityId === ringOfComfort.id);
|
|
expect(healingEvents.length).toBeGreaterThan(1);
|
|
expect(useCombatStore.getState().resourcePools.psi).toBe(1);
|
|
expect(usePartyStore.getState().members.find((candidate) => candidate.id === partyMember.id)?.resourcePools.psi ?? 0).toBe(0);
|
|
|
|
useCombatStore.setState((state) => ({
|
|
resourcePools: { ...state.resourcePools, psi: 5 },
|
|
cooldowns: { ...state.cooldowns, [ringOfComfort.id]: 0 },
|
|
}));
|
|
expect(useCombatStore.getState().castAbility(ringOfComfort.id, undefined, 12_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().resourcePools.psi).toBe(6);
|
|
});
|
|
|
|
it("grants one capped caster Psi from a generated Warlock/Priest Ring of Comfort cast", () => {
|
|
const { companion, ally, readyAbility } = prepareWarlockPriestSupportCompanion(498641, 5);
|
|
completeWarlockCompanionFriendlyCast(companion.id, readyAbility.id, 1_000, readyAbility.castTimeMs, ally.id);
|
|
const result = usePartyStore.getState().members.find((candidate) => candidate.id === companion.id)!;
|
|
expect(result.resourcePools.psi).toBe(6);
|
|
expect(usePartyStore.getState().members.find((candidate) => candidate.id === ally.id)?.resourcePools.psi ?? 0).toBe(0);
|
|
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "resource"
|
|
&& event.abilityId === readyAbility.id && event.targetActorId === companion.id)).toHaveLength(1);
|
|
});
|
|
|
|
it("routes Mind Shield's Urgent Heal Psi to the player caster, not its selected ally", () => {
|
|
prepareWarlock("rom-priest");
|
|
const urgentHeal = abilitiesForCharacter("rom-warlock", "rom-priest")
|
|
.find((candidate) => candidate.dbcSpellId === 491147)!;
|
|
const partyMember = prepareWarlockPriestFriendlyTarget();
|
|
const firstCast = useCombatStore.getState().castAbility(urgentHeal.id, undefined, 1_000);
|
|
expect(firstCast).toMatchObject({ ok: true });
|
|
finishCast(1_000);
|
|
expect(useCombatStore.getState().resourcePools.psi).toBe(1);
|
|
expect(usePartyStore.getState().members.find((candidate) => candidate.id === partyMember.id)?.resourcePools.psi ?? 0).toBe(0);
|
|
|
|
useCombatStore.setState((state) => ({
|
|
resourcePools: { ...state.resourcePools, psi: 5 },
|
|
cooldowns: { ...state.cooldowns, [urgentHeal.id]: 0 },
|
|
}));
|
|
const secondCast = useCombatStore.getState().castAbility(urgentHeal.id, undefined, 10_000);
|
|
expect(secondCast).toMatchObject({ ok: true });
|
|
finishCast(10_000);
|
|
expect(useCombatStore.getState().resourcePools.psi).toBe(6);
|
|
});
|
|
|
|
it("routes Mind Shield's companion Urgent Heal Psi to its Warlock/Priest caster", () => {
|
|
const { companion, ally, readyAbility } = prepareWarlockPriestSupportCompanion(491147, 5);
|
|
completeWarlockCompanionFriendlyCast(companion.id, readyAbility.id, 1_000, readyAbility.castTimeMs, ally.id);
|
|
const result = usePartyStore.getState().members.find((candidate) => candidate.id === companion.id)!;
|
|
expect(result.resourcePools.psi).toBe(6);
|
|
expect(usePartyStore.getState().members.find((candidate) => candidate.id === ally.id)?.resourcePools.psi ?? 0).toBe(0);
|
|
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "resource"
|
|
&& event.abilityId === readyAbility.id && event.targetActorId === companion.id)).toHaveLength(1);
|
|
});
|
|
|
|
it("uses current Warlock/Priest passive effects and pair upgrades", () => {
|
|
const context = {
|
|
sealStacks: 0,
|
|
bleedCount: 0,
|
|
charged: false,
|
|
mainHandDagger: false,
|
|
casterMaxHealth: 10_000,
|
|
targetHealthPercent: 1,
|
|
learnedPassiveSpellIds: [498633, 498643, 498648, 498668],
|
|
} as const;
|
|
const divineAssistance = ability(498668);
|
|
expect(divineAssistance.description).toContain("magical damage and healing by 16%");
|
|
expect(divineAssistance.effects).toContainEqual(expect.objectContaining({
|
|
kind: "apply-aura",
|
|
aura: expect.objectContaining({
|
|
durationMs: null,
|
|
modifiers: expect.arrayContaining([
|
|
expect.objectContaining({ kind: "damage", direction: "dealt", school: "shadow", value: 0.16 }),
|
|
expect.objectContaining({ kind: "healing", direction: "done", value: 0.16 }),
|
|
]),
|
|
}),
|
|
}));
|
|
|
|
const healingFortune = romEffectsForCast(ability(497957), context)
|
|
.find((effect) => effect.kind === "apply-aura");
|
|
expect(healingFortune).toMatchObject({
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
requiresLandedHit: true,
|
|
aura: {
|
|
durationMs: 20_000,
|
|
modifiers: expect.arrayContaining([
|
|
expect.objectContaining({ kind: "healing", direction: "done", value: 0.12 }),
|
|
expect.objectContaining({ kind: "stat", stat: "wisdom", value: 0.12 }),
|
|
]),
|
|
},
|
|
});
|
|
|
|
const pairAbilities = abilitiesForCharacter("rom-warlock", "rom-priest");
|
|
const risingTide = pairAbilities.find((candidate) => candidate.dbcSpellId === 490256)!;
|
|
expect(learnedAbilityForBuild(risingTide, 29, {})).toMatchObject({ castTimeMs: 2_000, cooldownMs: 0 });
|
|
const upgradedRisingTide = learnedAbilityForBuild(risingTide, 30, {})!;
|
|
expect(upgradedRisingTide).toMatchObject({ castMode: "instant", castTimeMs: 0, cooldownMs: 5_000 });
|
|
expect(upgradedRisingTide.description).toContain("cannot redirect an ally-targeted cast");
|
|
expect(romEffectsForCast(upgradedRisingTide, context)).toContainEqual({
|
|
kind: "resource", resource: "psi", amount: 1,
|
|
});
|
|
expect(romEffectsForCast(upgradedRisingTide, context).find((effect) => (
|
|
effect.kind === "apply-aura" && effect.requiresCriticalHit
|
|
))).toBeDefined();
|
|
|
|
const warpCharge = pairAbilities.find((candidate) => candidate.dbcSpellId === 497958)!;
|
|
expect(learnedAbilityForBuild(warpCharge, 14, {})?.target).toBe("hostile");
|
|
const upgradedWarpCharge = learnedAbilityForBuild(warpCharge, 15, {})!;
|
|
expect(upgradedWarpCharge.target).toBe("any");
|
|
const friendlyEffects = romEffectsForCast(upgradedWarpCharge, {
|
|
...context,
|
|
selectedTargetRelationship: "friendly",
|
|
});
|
|
expect(friendlyEffects.some((effect) => effect.kind === "heal")).toBe(true);
|
|
expect(friendlyEffects.some((effect) => effect.kind === "damage")).toBe(false);
|
|
expect(romEffectsForCast(upgradedWarpCharge, {
|
|
...context,
|
|
selectedTargetRelationship: "hostile",
|
|
}).some((effect) => effect.kind === "damage")).toBe(true);
|
|
|
|
expect(romEffectsForCast(ability(491147), context)).toContainEqual({
|
|
kind: "resource", resource: "psi", amount: 1, scope: "caster",
|
|
});
|
|
expect(ability(498633).description).toContain("12% Wisdom for 20 seconds");
|
|
expect(ability(498648).description).toContain("Ring of Comfort's reset chance is not specified");
|
|
});
|
|
|
|
it("applies Healing Fortune and Divine Assistance for the player and generated companion", () => {
|
|
prepareWarlock("rom-priest");
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.id === "rom-warlock-divine-assistance-498668")).toBe(true);
|
|
registerTarget();
|
|
|
|
const psychicArrows = ability(497957);
|
|
expect(useCombatStore.getState().castAbility(psychicArrows.id, {
|
|
targetActorId: TARGET_ID,
|
|
now: 1_000,
|
|
}).ok).toBe(true);
|
|
finishCast(1_000);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.id === "rom-warlock-healing-fortune-498633")).toBe(true);
|
|
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-dwarf", level: 80 });
|
|
usePartyStore.getState().initializeParty("rom-warlock-priest-companion", 80,
|
|
useCombatStore.getState().health, "damage", 2, "rom");
|
|
const companion = createConfiguredPartyMember({
|
|
name: "Warlock/Priest Companion",
|
|
classId: "rom-warlock",
|
|
secondaryClassId: "rom-priest",
|
|
raceId: "rom-dwarf",
|
|
specialization: "Warlock",
|
|
role: "damage",
|
|
}, 80, 0, "rom-warlock-priest-companion");
|
|
const pairAbilities = abilitiesForCharacter("rom-warlock", "rom-priest");
|
|
usePartyStore.setState({
|
|
command: "attack",
|
|
members: [{
|
|
...companion,
|
|
cooldowns: Object.fromEntries(pairAbilities
|
|
.filter((candidate) => candidate.dbcSpellId !== 497957)
|
|
.map((candidate) => [candidate.id, 100_000])),
|
|
resourcePools: { ...companion.resourcePools, focus: 100 },
|
|
resource: 100,
|
|
}],
|
|
});
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerPartyRuntimePosition(companion.id, [0, 0, 0]);
|
|
registerTarget();
|
|
useCombatStore.getState().damageMob(TARGET_ID, 1, 900);
|
|
updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true);
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
advanceDungeonPartyCombat(3_100);
|
|
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === companion.id
|
|
&& aura.targetId === companion.id
|
|
&& aura.definition.id === "rom-warlock-divine-assistance-498668")).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === companion.id
|
|
&& aura.targetId === companion.id
|
|
&& aura.definition.id === "rom-warlock-healing-fortune-498633")).toBe(true);
|
|
});
|
|
|
|
it("turns friendly Warp Charge into a heal for a Warlock/Priest player", () => {
|
|
prepareWarlock("rom-priest");
|
|
useCombatStore.setState((state) => ({
|
|
health: Math.max(1, state.maxHealth - 100),
|
|
}));
|
|
usePartyStore.getState().selectSelf();
|
|
const warpCharge = abilitiesForCharacter("rom-warlock", "rom-priest")
|
|
.find((candidate) => candidate.dbcSpellId === 497958)!;
|
|
const result = useCombatStore.getState().castAbility(warpCharge.id, {
|
|
now: 1_000,
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(useCombatStore.getState().maxHealth - 100);
|
|
});
|
|
|
|
it("applies the pair upgrade when the player casts Rising Tide", () => {
|
|
prepareWarlock("rom-priest");
|
|
registerTarget();
|
|
const risingTide = abilitiesForCharacter("rom-warlock", "rom-priest")
|
|
.find((candidate) => candidate.dbcSpellId === 490256)!;
|
|
expect(useCombatStore.getState().castAbility(risingTide.id, {
|
|
targetActorId: TARGET_ID,
|
|
now: 1_000,
|
|
}).ok).toBe(true);
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
expect(useCombatStore.getState().resourcePools.psi).toBe(1);
|
|
expect(useCombatStore.getState().cooldowns[risingTide.id]).toBe(6_000);
|
|
});
|
|
|
|
it("consumes all Rage for Heart Fence and extends its duration by one second per 20 Rage", () => {
|
|
prepareWarlock("rom-warrior");
|
|
usePartyStore.getState().initializeParty(
|
|
"rom-warlock-heart-fence", 80, useCombatStore.getState().health, "damage", 3, "rom",
|
|
);
|
|
const member = usePartyStore.getState().members[0]!;
|
|
registerPartyRuntimePosition(member.id, [4, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 45 } }));
|
|
|
|
const heartFence = ability(498645);
|
|
expect(useCombatStore.getState().castAbility(heartFence.id, {
|
|
targetActorId: member.id,
|
|
now: 1_000,
|
|
}).ok).toBe(true);
|
|
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
|
|
const defenseAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id
|
|
&& aura.definition.name.startsWith("Heart Fence")
|
|
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken"));
|
|
expect(defenseAuras).toHaveLength(2);
|
|
expect(defenseAuras.every((aura) => aura.expiresAt === 5_000)).toBe(true);
|
|
});
|
|
|
|
it("only refreshes state 501577 and restores Rage when Consistent Heart Strike finds it", () => {
|
|
prepareWarlock("rom-warrior");
|
|
registerTarget();
|
|
const heartStrike = ability(498646);
|
|
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 25 } }));
|
|
|
|
expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 500 }).ok).toBe(true);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(25);
|
|
|
|
const soulTrauma = ability(498550);
|
|
const traumaCast = useCombatStore.getState().castAbility(soulTrauma.id, { targetActorId: TARGET_ID, now: 2_000 });
|
|
expect(traumaCast, JSON.stringify(traumaCast)).toMatchObject({ ok: true });
|
|
const heartStatesAfterTrauma = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID
|
|
&& aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence));
|
|
expect(heartStatesAfterTrauma.some((aura) => aura.expiresAt === 17_000)).toBe(true);
|
|
|
|
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 20 } }));
|
|
expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 5_000 }).ok).toBe(true);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(100);
|
|
const heartStatesAfterStrike = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID
|
|
&& aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence));
|
|
expect(heartStatesAfterStrike.length).toBeGreaterThan(0);
|
|
expect(heartStatesAfterStrike.every((aura) => aura.expiresAt === 20_000)).toBe(true);
|
|
});
|
|
|
|
it("scales Crime and Punishment by 25% per state after the first", () => {
|
|
const crimeAndPunishment = ability(498700);
|
|
const baseDamage = crimeAndPunishment.effects.find((effect) => effect.kind === "damage");
|
|
if (baseDamage?.kind !== "damage") throw new Error("Missing Crime and Punishment damage effect");
|
|
const effectsFor = (targetTags: readonly string[]) => romEffectsForCast(crimeAndPunishment, {
|
|
sealStacks: 0,
|
|
bleedCount: 0,
|
|
charged: false,
|
|
mainHandDagger: false,
|
|
casterMaxHealth: 10_000,
|
|
targetHealthPercent: 1,
|
|
targetTags,
|
|
});
|
|
|
|
const oneState = effectsFor([
|
|
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
|
|
]).find((effect) => effect.kind === "damage");
|
|
const twoStates = effectsFor([
|
|
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
|
|
ROM_WARLOCK_STATE_TAGS.soulPain,
|
|
]).find((effect) => effect.kind === "damage");
|
|
const threeStates = effectsFor([
|
|
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
|
|
ROM_WARLOCK_STATE_TAGS.soulPain,
|
|
ROM_WARLOCK_STATE_TAGS.soulPoisonedFang,
|
|
]).find((effect) => effect.kind === "damage");
|
|
expect(oneState).toEqual(baseDamage);
|
|
expect(twoStates).toMatchObject({
|
|
coefficient: baseDamage.coefficient * 1.25,
|
|
basePoints: baseDamage.basePoints! * 1.25,
|
|
});
|
|
expect(threeStates).toMatchObject({
|
|
coefficient: baseDamage.coefficient * 1.5,
|
|
basePoints: baseDamage.basePoints! * 1.5,
|
|
});
|
|
});
|
|
|
|
it("refreshes the active three Warlock state effects after Crime and Punishment", () => {
|
|
prepareWarlock("rom-rogue");
|
|
registerTarget();
|
|
for (const spellId of [497960, 497964, 498670]) {
|
|
const stateSpell = ability(spellId);
|
|
const castAt = spellId === 497960 ? 1_000 : spellId === 497964 ? 2_500 : 4_000;
|
|
expect(useCombatStore.getState().castAbility(stateSpell.id, { targetActorId: TARGET_ID, now: castAt }).ok).toBe(true);
|
|
finishCast(castAt);
|
|
}
|
|
useCombatStore.getState().tick(2.1, 6_100);
|
|
const before = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot"
|
|
&& effect.targetId === TARGET_ID
|
|
&& ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`));
|
|
expect(before.map((effect) => effect.remainingTicks).every((ticks) => ticks > 0)).toBe(true);
|
|
expect(before).toHaveLength(3);
|
|
expect(before.every((effect) => {
|
|
const sourceDot = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId)
|
|
?.effects.find((entry): entry is Extract<AbilityEffect, { kind: "dot" }> => (
|
|
entry.kind === "dot" && entry.tag === effect.tag
|
|
));
|
|
if (sourceDot?.kind !== "dot") return false;
|
|
return effect.remainingTicks < sourceDot.ticks;
|
|
})).toBe(true);
|
|
|
|
const crimeAndPunishment = ability(498700);
|
|
expect(useCombatStore.getState().castAbility(crimeAndPunishment.id, { targetActorId: TARGET_ID, now: 6_200 }).ok).toBe(true);
|
|
const after = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot"
|
|
&& effect.targetId === TARGET_ID
|
|
&& ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`));
|
|
expect(after).toHaveLength(before.length);
|
|
expect(after.every((effect) => effect.nextTickAt > 6_200)).toBe(true);
|
|
expect(after.map((effect) => effect.remainingTicks)).toEqual(before.map((effect) => {
|
|
const source = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId);
|
|
return source?.effects.find((entry): entry is Extract<AbilityEffect, { kind: "dot" }> => (
|
|
entry.kind === "dot" && entry.tag === effect.tag
|
|
))?.ticks;
|
|
}));
|
|
});
|
|
|
|
it("applies Liquidation Suffering to its selected ally and the rest of the party", () => {
|
|
prepareWarlock("rom-rogue");
|
|
usePartyStore.getState().initializeParty(
|
|
"rom-warlock-liquidation-suffering", 80, useCombatStore.getState().health, "damage", 3, "rom",
|
|
);
|
|
const [selectedAlly, otherAlly] = usePartyStore.getState().members;
|
|
expect(selectedAlly).toBeDefined();
|
|
expect(otherAlly).toBeDefined();
|
|
registerPartyRuntimePosition(selectedAlly!.id, [4, 0, 0]);
|
|
registerPartyRuntimePosition(otherAlly!.id, [6, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
|
|
const liquidation = ability(498695);
|
|
expect(useCombatStore.getState().castAbility(liquidation.id, {
|
|
targetActorId: selectedAlly!.id,
|
|
now: 1_000,
|
|
}).ok).toBe(true);
|
|
|
|
const threatAuras = useCombatStore.getState().auras.filter((aura) => (
|
|
aura.definition.name.startsWith("Liquidation Suffering")
|
|
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "threat")
|
|
));
|
|
const modifierFor = (targetId: string) => threatAuras.find((aura) => aura.targetId === targetId)
|
|
?.definition.modifiers?.find((modifier) => modifier.kind === "stat" && modifier.stat === "threat");
|
|
expect(modifierFor(selectedAlly!.id)).toMatchObject({ value: 0.05 });
|
|
expect(modifierFor(PLAYER_AURA_ENTITY_ID)).toMatchObject({ value: -0.05 });
|
|
expect(modifierFor(otherAlly!.id)).toMatchObject({ value: -0.05 });
|
|
expect(threatAuras.filter((aura) => aura.definition.modifiers?.some((modifier) => (
|
|
modifier.kind === "stat" && modifier.stat === "threat" && modifier.value > 0
|
|
)))).toHaveLength(1);
|
|
expect(threatAuras.some((aura) => aura.targetId === selectedAlly!.id
|
|
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
|
&& modifier.stat === "threat" && modifier.value < 0))).toBe(false);
|
|
});
|
|
|
|
it("grants Defense Net's source-described control immunity to its friendly target", () => {
|
|
prepareWarlock("rom-warrior");
|
|
usePartyStore.getState().initializeParty(
|
|
"rom-warlock-defense-net", 80, useCombatStore.getState().health, "damage", 3, "rom",
|
|
);
|
|
const member = usePartyStore.getState().members[0]!;
|
|
registerPartyRuntimePosition(member.id, [4, 0, 0]);
|
|
usePartyStore.setState((party) => ({
|
|
members: party.members.map((candidate) => candidate.id === member.id
|
|
? { ...candidate, controlMechanic: "stun" as const, controlledUntil: 5_000 }
|
|
: candidate),
|
|
}));
|
|
useCombatStore.getState().synchronizeActors();
|
|
|
|
const defenseNet = ability(498549);
|
|
expect(useCombatStore.getState().castAbility(defenseNet.id, {
|
|
targetActorId: member.id,
|
|
now: 1_000,
|
|
}).ok).toBe(true);
|
|
finishCast(1_000);
|
|
|
|
const updatedMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id);
|
|
expect(updatedMember).toMatchObject({ controlMechanic: null, controlledUntil: 0 });
|
|
const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id
|
|
&& candidate.definition.name.startsWith("Defense Net"));
|
|
expect(aura).toMatchObject({
|
|
expiresAt: 6_000,
|
|
definition: {
|
|
controlImmunities: ["fear", "stun", "root", "slow"],
|
|
modifiers: expect.arrayContaining([expect.objectContaining({ direction: "taken", value: -0.5 })]),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reduces Defense Net to four seconds only for the Warlock/Priest Mind Shield pair", () => {
|
|
prepareWarlock("rom-priest");
|
|
usePartyStore.getState().initializeParty(
|
|
"rom-warlock-mind-shield", 80, useCombatStore.getState().health, "damage", 3, "rom",
|
|
);
|
|
const member = usePartyStore.getState().members[0]!;
|
|
registerPartyRuntimePosition(member.id, [4, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
|
|
const defenseNet = ability(498549);
|
|
expect(useCombatStore.getState().castAbility(defenseNet.id, {
|
|
targetActorId: member.id,
|
|
now: 1_000,
|
|
}).ok).toBe(true);
|
|
finishCast(1_000);
|
|
|
|
expect(useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id
|
|
&& candidate.definition.name.startsWith("Defense Net"))?.expiresAt).toBe(5_000);
|
|
});
|
|
|
|
it("resolves Warlock paired named resets to only their source-named spells", () => {
|
|
const expectations = new Map<number, number[]>([
|
|
[498744, [491156]], // Static Resonance -> Electrostatic Charge
|
|
[498758, [490225, 490251]], // Deep Inspiration -> Intensification and Energy Well
|
|
[498662, [490256]], // Touch of Faith -> Rising Tide
|
|
[498710, [498697]], // Soul Brand Sting -> Flaming Heart Strike
|
|
]);
|
|
for (const [sourceSpellId, expectedTargets] of expectations) {
|
|
const source = ability(sourceSpellId);
|
|
const activePair = abilitiesForCharacter(source.classId, source.secondaryClassId);
|
|
const targets = source.effects.flatMap((effect) => effect.kind === "cooldown-reset"
|
|
? cooldownResetCandidates(activePair, source.id, effect.abilityName).map((candidate) => candidate.dbcSpellId)
|
|
: []);
|
|
expect(targets.sort((left, right) => left - right), `source ${sourceSpellId}`).toEqual(
|
|
[...expectedTargets].sort((left, right) => left - right),
|
|
);
|
|
}
|
|
for (const sourceSpellId of [498634, 498648, 498668]) {
|
|
expect(ability(sourceSpellId).effects.some((effect) => effect.kind === "cooldown-reset"), `source ${sourceSpellId}`)
|
|
.toBe(false);
|
|
}
|
|
expect(ROM_ALL_ABILITIES.filter((candidate) => candidate.classId === "rom-warlock")
|
|
.flatMap((candidate) => candidate.effects.filter((effect) => effect.kind === "cooldown-reset"
|
|
&& effect.abilityName === undefined).map((effect) => candidate.dbcSpellId))).toEqual([]);
|
|
});
|
|
});
|