366 lines
17 KiB
TypeScript
366 lines
17 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { abilityById, resourceProfileForClass } from "./abilityCatalog";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { applyAura, resolveResourceValue } from "./combatAuras";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
ROM_MAGE_WARLOCK_PASSIVE_TAGS,
|
|
romMageWarlockCastEffects,
|
|
} from "./romMageWarlockOverrides";
|
|
import { applyTalentModifierValue } from "./talentModifiers";
|
|
|
|
function initializeMage(secondaryClassId?: "rom-druid" | "rom-warden" | "rom-champion", level = 80): void {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-mage",
|
|
secondaryClassId,
|
|
raceId: "rom-human",
|
|
level,
|
|
});
|
|
}
|
|
|
|
function addTarget(id: string, x: number, z = 0): void {
|
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
|
|
setMobPosition(id, [x, 0, z]);
|
|
}
|
|
|
|
describe("RoM Mage mechanics", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
|
});
|
|
|
|
it("maps Elemental Catalysis mana percentage regeneration against maximum mana", () => {
|
|
const catalysis = abilityById("rom-mage-primary-0")!;
|
|
expect(catalysis.effects).toContainEqual(expect.objectContaining({
|
|
kind: "apply-aura",
|
|
aura: expect.objectContaining({
|
|
durationMs: 20_000,
|
|
modifiers: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: "stat",
|
|
stat: "mana-regeneration-max-percent-per-second",
|
|
operation: "flat",
|
|
value: 0.01,
|
|
}),
|
|
]),
|
|
}),
|
|
}));
|
|
|
|
initializeMage(undefined, 60);
|
|
addTarget("catalysis-combat", 3);
|
|
useCombatStore.setState((state) => ({
|
|
...state,
|
|
resource: state.maxResource / 2,
|
|
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 },
|
|
lastResourceSpentAt: { mana: 1_000 },
|
|
mobs: { ...state.mobs, "catalysis-combat": { ...state.mobs["catalysis-combat"]!, engaged: true } },
|
|
}));
|
|
const before = useCombatStore.getState().resourcePools.mana!;
|
|
expect(useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
|
&& modifier.stat === "mana-regeneration-max-percent-per-second" && modifier.value === 0.01))).toBe(true);
|
|
useCombatStore.getState().tick(1, 2_000);
|
|
useCombatStore.getState().tick(1, 3_000);
|
|
const restored = useCombatStore.getState().resourcePools.mana! - before;
|
|
expect(restored).toBeGreaterThanOrEqual(useCombatStore.getState().maxResourcePools.mana! * 0.02);
|
|
});
|
|
|
|
it("uses rank-two Catalysis's explicit 20-second damage and spell-speed payload without adding a mana tick", () => {
|
|
initializeMage(undefined, 65);
|
|
const catalysis = abilityById("rom-mage-primary-0")!;
|
|
const rankTwo = catalysis.ranks?.find((rank) => rank.spellId === 497971);
|
|
expect(rankTwo).toBeDefined();
|
|
expect(rankTwo?.effects).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: "apply-aura",
|
|
aura: expect.objectContaining({
|
|
durationMs: 20_000,
|
|
modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", attackKind: "spell", value: 0.2 })],
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "apply-aura",
|
|
aura: expect.objectContaining({
|
|
durationMs: 20_000,
|
|
modifiers: [expect.objectContaining({ kind: "stat", stat: "spell-haste", value: 50 })],
|
|
}),
|
|
}),
|
|
]));
|
|
expect(rankTwo?.effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
|
&& modifier.stat === "mana-regeneration-max-percent-per-second"))).toBe(false);
|
|
|
|
addTarget("catalysis-rank-two-combat", 3);
|
|
useCombatStore.setState((state) => ({
|
|
...state,
|
|
lastResourceSpentAt: { mana: 1_000 },
|
|
mobs: { ...state.mobs, "catalysis-rank-two-combat": { ...state.mobs["catalysis-rank-two-combat"]!, engaged: true } },
|
|
}));
|
|
const result = useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000);
|
|
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.id.includes("497971")
|
|
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage"
|
|
&& modifier.attackKind === "spell" && modifier.value === 0.2))).toBe(true);
|
|
});
|
|
|
|
it("applies rank-two Catalysis spell haste to Mage cast time while rank one stays unchanged", () => {
|
|
const flame = abilityById("rom-mage-primary-1")!;
|
|
expect(flame).toMatchObject({ name: "Flame", castTimeMs: 3_000 });
|
|
|
|
initializeMage(undefined, 65);
|
|
addTarget("catalysis-haste-rank-two-baseline", 3);
|
|
useCombatStore.getState().selectMob("catalysis-haste-rank-two-baseline");
|
|
expect(useCombatStore.getState().castAbility(flame.id, undefined, 1_000)).toMatchObject({ ok: true });
|
|
const rankTwoBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
|
|
|
|
initializeMage(undefined, 65);
|
|
clearMobRuntimeRegistry();
|
|
addTarget("catalysis-haste-rank-two-target", 3);
|
|
useCombatStore.getState().selectMob("catalysis-haste-rank-two-target");
|
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 3_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.id.includes("497971")
|
|
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
|
&& modifier.stat === "spell-haste" && modifier.value === 50))).toBe(true);
|
|
expect(useCombatStore.getState().castAbility(flame.id, undefined, 5_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().activeCast).toMatchObject({
|
|
abilityId: flame.id,
|
|
startedAt: 5_000,
|
|
originalDurationMs: Math.round(rankTwoBaselineMs / 1.5),
|
|
completesAt: 5_000 + Math.round(rankTwoBaselineMs / 1.5),
|
|
});
|
|
|
|
initializeMage(undefined, 60);
|
|
clearMobRuntimeRegistry();
|
|
addTarget("catalysis-haste-rank-one-baseline", 3);
|
|
useCombatStore.getState().selectMob("catalysis-haste-rank-one-baseline");
|
|
expect(useCombatStore.getState().castAbility(flame.id, undefined, 7_000)).toMatchObject({ ok: true });
|
|
const rankOneBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
|
|
|
|
initializeMage(undefined, 60);
|
|
clearMobRuntimeRegistry();
|
|
addTarget("catalysis-haste-rank-one-target", 3);
|
|
useCombatStore.getState().selectMob("catalysis-haste-rank-one-target");
|
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 9_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
|
&& modifier.stat === "spell-haste"))).toBe(false);
|
|
expect(useCombatStore.getState().castAbility(flame.id, undefined, 11_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().activeCast).toMatchObject({
|
|
abilityId: flame.id,
|
|
startedAt: 11_000,
|
|
originalDurationMs: rankOneBaselineMs,
|
|
completesAt: 11_000 + rankOneBaselineMs,
|
|
});
|
|
});
|
|
|
|
it("does not apply a RoM spell-haste aura to another class's cast time", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
|
|
addTarget("non-mage-haste-baseline", 3);
|
|
useCombatStore.getState().selectMob("non-mage-haste-baseline");
|
|
expect(useCombatStore.getState().castAbility("priest-smite", undefined, 1_000)).toMatchObject({ ok: true });
|
|
const priestBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
|
|
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
|
|
clearMobRuntimeRegistry();
|
|
addTarget("non-mage-haste-target", 3);
|
|
useCombatStore.getState().selectMob("non-mage-haste-target");
|
|
useCombatStore.setState((state) => ({
|
|
auras: applyAura(state.auras, {
|
|
definition: {
|
|
id: "rom-catalysis-non-mage-test",
|
|
name: "Test Catalysis haste",
|
|
disposition: "buff",
|
|
durationMs: 20_000,
|
|
modifiers: [{ kind: "stat", stat: "spell-haste", operation: "flat", value: 50 }],
|
|
},
|
|
sourceId: PLAYER_AGGRO_ID,
|
|
targetId: PLAYER_AGGRO_ID,
|
|
now: 3_000,
|
|
}).auras,
|
|
}));
|
|
expect(useCombatStore.getState().castAbility("priest-smite", undefined, 3_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().activeCast).toMatchObject({
|
|
abilityId: "priest-smite",
|
|
originalDurationMs: priestBaselineMs,
|
|
completesAt: 3_000 + priestBaselineMs,
|
|
});
|
|
});
|
|
|
|
it("leaves ordinary mana regeneration unchanged without the Catalysis aura", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
|
|
addTarget("ordinary-mana-combat", 3);
|
|
useCombatStore.setState((state) => ({
|
|
...state,
|
|
resource: state.maxResource / 2,
|
|
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 },
|
|
lastResourceSpentAt: { mana: 1_000 },
|
|
mobs: { ...state.mobs, "ordinary-mana-combat": { ...state.mobs["ordinary-mana-combat"]!, engaged: true } },
|
|
}));
|
|
const beforeState = useCombatStore.getState();
|
|
const before = beforeState.resourcePools.mana!;
|
|
const playerAuras = beforeState.auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID);
|
|
const baseRegeneration = applyTalentModifierValue(
|
|
resourceProfileForClass("priest").regenerationPerSecond
|
|
+ beforeState.gearStats.mp5 / 5
|
|
+ beforeState.gearStats.spirit * 0.02,
|
|
beforeState.talentModifiers,
|
|
"resource-regeneration-percent",
|
|
);
|
|
const expectedPerSecond = resolveResourceValue(
|
|
resolveResourceValue(baseRegeneration, playerAuras, "mana", "regeneration"),
|
|
playerAuras,
|
|
"power",
|
|
"regeneration",
|
|
);
|
|
expect(beforeState.auras.some((aura) => aura.definition.modifiers?.some((modifier) => (
|
|
modifier.kind === "stat" && modifier.stat === "mana-regeneration-max-percent-per-second"
|
|
)))).toBe(false);
|
|
useCombatStore.getState().tick(1, 2_000);
|
|
useCombatStore.getState().tick(1, 3_000);
|
|
expect(useCombatStore.getState().resourcePools.mana! - before).toBeCloseTo(expectedPerSecond * 2);
|
|
});
|
|
|
|
it("casts Phoenix down a capped line and leaves other line spells' extension intact", () => {
|
|
initializeMage();
|
|
for (const [id, x, z] of [
|
|
["phoenix-target", 4, 0],
|
|
["phoenix-path", 8, 0],
|
|
["phoenix-off-line", 7, 2],
|
|
["phoenix-beyond", 12.1, 0],
|
|
] as const) addTarget(id, x, z);
|
|
useCombatStore.getState().selectMob("phoenix-target");
|
|
expect(abilityById("rom-mage-primary-6")).toMatchObject({
|
|
target: "hostile",
|
|
range: { max: 12 },
|
|
targeting: { shape: "line", radius: 12 },
|
|
});
|
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-6", undefined, 1_000))
|
|
.toMatchObject({ ok: true, affectedMobIds: ["phoenix-target", "phoenix-path"] });
|
|
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-elf", level: 60 });
|
|
clearMobRuntimeRegistry();
|
|
for (const [id, x] of [["arrow-target", 3], ["arrow-path", 7], ["arrow-beyond", 7.1]] as const) {
|
|
addTarget(id, x);
|
|
}
|
|
useCombatStore.getState().selectMob("arrow-target");
|
|
expect(useCombatStore.getState().castAbility("rom-scout-primary-5", undefined, 4_000))
|
|
.toMatchObject({ ok: true, affectedMobIds: ["arrow-target", "arrow-path"] });
|
|
});
|
|
|
|
it("doubles only Fireball and Flame on the 10% Eruption proc and runs Control Flame once", () => {
|
|
const fireball = abilityById("rom-mage-general-0")!;
|
|
const tags = new Set([
|
|
ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption,
|
|
ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame,
|
|
]);
|
|
const procced = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.099);
|
|
const ordinary = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.1);
|
|
expect(procced.filter((effect) => effect.kind === "damage")).toEqual(
|
|
fireball.effects.filter((effect) => effect.kind === "damage").map((effect) => effect.kind === "damage"
|
|
? expect.objectContaining({
|
|
coefficient: effect.coefficient * 2,
|
|
basePoints: (effect.basePoints ?? 0) * 2,
|
|
pointsPerLevel: (effect.pointsPerLevel ?? 0) * 2,
|
|
})
|
|
: effect),
|
|
);
|
|
expect(ordinary.filter((effect) => effect.kind === "damage")).toEqual(
|
|
fireball.effects.filter((effect) => effect.kind === "damage"),
|
|
);
|
|
expect(procced).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: "apply-aura",
|
|
aura: expect.objectContaining({
|
|
id: "rom-mage-druid-control-flame-buff",
|
|
durationMs: 12_000,
|
|
modifiers: [expect.objectContaining({ stat: "magical-attack", value: 0.08 })],
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "apply-aura",
|
|
aura: expect.objectContaining({
|
|
id: "rom-mage-druid-control-flame-cooldown",
|
|
durationMs: 20_000,
|
|
}),
|
|
}),
|
|
]));
|
|
expect(romMageWarlockCastEffects(fireball, fireball.effects, new Set([
|
|
...tags,
|
|
ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown,
|
|
]), 0.099).some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.id === "rom-mage-druid-control-flame-buff")).toBe(false);
|
|
});
|
|
|
|
it("applies Eruption and Control Flame when a Fireball proc succeeds", () => {
|
|
initializeMage("rom-druid");
|
|
addTarget("eruption-target", 3);
|
|
useCombatStore.getState().selectMob("eruption-target");
|
|
const before = useCombatStore.getState().mobs["eruption-target"]!.health;
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption))).toBe(true);
|
|
expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_002)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().mobs["eruption-target"]!.health).toBeLessThan(before);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff"))
|
|
.toBe(true);
|
|
const flameBuff = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff");
|
|
expect(flameBuff?.definition.modifiers).toEqual([
|
|
expect.objectContaining({ kind: "stat", stat: "magical-attack", operation: "percent", value: 0.08 }),
|
|
]);
|
|
});
|
|
|
|
it("reduces Fireball cooldown by one second with Flame Follower", () => {
|
|
initializeMage("rom-warden");
|
|
addTarget("follower-target", 3);
|
|
useCombatStore.getState().selectMob("follower-target");
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.flameFollower))).toBe(true);
|
|
expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().cooldowns["rom-mage-general-0"]).toBe(6_000);
|
|
});
|
|
|
|
it("resets only Lightning and Discharge with Brain Shock", () => {
|
|
initializeMage("rom-champion");
|
|
useCombatStore.setState((state) => ({
|
|
cooldowns: {
|
|
...state.cooldowns,
|
|
"rom-mage-general-1": 8_000,
|
|
"rom-mage-primary-4": 10_000,
|
|
"rom-mage-general-0": 9_000,
|
|
},
|
|
resourcePools: { ...state.resourcePools, rage: 100 },
|
|
}));
|
|
|
|
expect(useCombatStore.getState().castAbility("rom-mage-rom-champion-elite-20", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().cooldowns).toMatchObject({
|
|
"rom-mage-general-1": 0,
|
|
"rom-mage-primary-4": 0,
|
|
"rom-mage-general-0": 9_000,
|
|
});
|
|
});
|
|
|
|
it("reduces Electrostatic Charge cooldown by 30 seconds with Mage/Champion expertise", () => {
|
|
initializeMage(undefined, 80);
|
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000);
|
|
|
|
initializeMage("rom-champion", 40);
|
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000);
|
|
|
|
initializeMage("rom-champion", 80);
|
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(91_000);
|
|
});
|
|
|
|
it("keeps Mage Fire Ward party-scoped", () => {
|
|
const fireWard = abilityById("rom-mage-general-5")!;
|
|
expect(fireWard.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party"))
|
|
.toBe(true);
|
|
});
|
|
});
|