333 lines
17 KiB
TypeScript
333 lines
17 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { TALENT_NODES, allocateTalentPoint } from "./talentCatalog";
|
|
import {
|
|
applyTalentModifierValue,
|
|
parseTalentModifiers,
|
|
talentUsesExplicitActivation,
|
|
talentModifiersForRanks,
|
|
} from "./talentModifiers";
|
|
|
|
describe("talent runtime modifiers", () => {
|
|
it("turns Twin Disciplines ranks into scoped damage and healing benefits", () => {
|
|
const twinDisciplines = TALENT_NODES.find((node) => node.name === "Twin Disciplines")!;
|
|
let ranks = {};
|
|
ranks = allocateTalentPoint(ranks, "priest", twinDisciplines.id, 80).ranks;
|
|
const rankOne = talentModifiersForRanks(ranks, "priest");
|
|
expect(rankOne).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "damage-percent", amount: 1, rank: 1, sourceTalentId: twinDisciplines.id }),
|
|
expect.objectContaining({ kind: "healing-percent", amount: 1, rank: 1, sourceTalentId: twinDisciplines.id }),
|
|
]));
|
|
expect(applyTalentModifierValue(100, rankOne, "healing-percent", { tags: ["instant", "holy"] })).toBe(101);
|
|
expect(applyTalentModifierValue(100, rankOne, "healing-percent", { tags: ["cast-time", "holy"] })).toBe(101);
|
|
|
|
for (let rank = 1; rank < twinDisciplines.maxRank; rank += 1) {
|
|
ranks = allocateTalentPoint(ranks, "priest", twinDisciplines.id, 80).ranks;
|
|
}
|
|
const maxRank = talentModifiersForRanks(ranks, "priest");
|
|
expect(applyTalentModifierValue(100, maxRank, "damage-percent", { tags: ["instant", "shadow"] })).toBe(100);
|
|
expect(applyTalentModifierValue(100, maxRank, "damage-percent", { tags: ["cast-time", "holy"] })).toBe(105);
|
|
});
|
|
|
|
it("keeps Arcane Potency's next-spell crit bonus out of passive modifiers", () => {
|
|
for (const rank of [1, 2]) {
|
|
const modifiers = talentModifiersForRanks({ "talent-1725": rank }, "mage");
|
|
expect(modifiers.filter((modifier) => modifier.kind === "critical-chance-percent")).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("keeps Molten Fury's target-health bonus out of unconditional scalar modifiers", () => {
|
|
for (const rank of [1, 2]) {
|
|
const modifiers = talentModifiersForRanks({ "talent-1732": rank }, "mage");
|
|
expect(modifiers.filter((modifier) => modifier.kind === "damage-percent")).toEqual([]);
|
|
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
|
|
abilityName: "Fireball",
|
|
tags: ["damage", "fire"],
|
|
})).toBe(100);
|
|
}
|
|
});
|
|
|
|
it("keeps Master of Elements' passive crit modifier single-counted", () => {
|
|
const modifiers = talentModifiersForRanks({ "talent-1639": 3 }, "mage");
|
|
expect(modifiers.filter((modifier) => modifier.kind === "critical-chance-percent")).toEqual([
|
|
expect.objectContaining({
|
|
amount: 3,
|
|
sourceTalentId: "talent-1639",
|
|
sourceTalentName: "Master of Elements",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("parses common passive stat, cost, cast-time, cooldown, and mitigation categories", () => {
|
|
expect(parseTalentModifiers("Increases your maximum health by 10%.")).toContainEqual(
|
|
expect.objectContaining({ kind: "max-health-percent", amount: 10 }),
|
|
);
|
|
expect(parseTalentModifiers("Reduces the mana cost of your healing spells by 6%.")).toContainEqual(
|
|
expect.objectContaining({ kind: "resource-cost-percent", amount: -6 }),
|
|
);
|
|
expect(parseTalentModifiers("Reduces the casting time of Heal by 0.5 sec.")).toContainEqual(
|
|
expect.objectContaining({ kind: "cast-time-flat-ms", amount: -500 }),
|
|
);
|
|
expect(parseTalentModifiers("Reduces the cooldown on Penance by 2 sec.")).toContainEqual(
|
|
expect.objectContaining({ kind: "cooldown-flat-ms", amount: -2000 }),
|
|
);
|
|
expect(parseTalentModifiers("Reduces all damage taken by 3%.")).toContainEqual(
|
|
expect.objectContaining({ kind: "damage-taken-percent", amount: -3 }),
|
|
);
|
|
expect(parseTalentModifiers(
|
|
"Chrono SorceryRank 2Instant castIncreases maximum mana by 12% and spell damage by 15% of Intellect.",
|
|
)).toContainEqual(
|
|
expect.objectContaining({ kind: "max-resource-percent", amount: 12 }),
|
|
);
|
|
expect(parseTalentModifiers("Increases spell power by 30% of your Intellect.")).not.toContainEqual(
|
|
expect.objectContaining({ kind: "attribute-percent", attribute: "intellect" }),
|
|
);
|
|
expect(parseTalentModifiers("Increases your Strength and Intellect by 10%.")).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ kind: "attribute-percent", attribute: "strength", amount: 10 }),
|
|
expect.objectContaining({ kind: "attribute-percent", attribute: "intellect", amount: 10 }),
|
|
]),
|
|
);
|
|
const focusedWill = parseTalentModifiers(
|
|
"Increases your spell critical effect chance by 3%, reducing all damage taken by 4% and increasing healing effects on you by 5%.",
|
|
);
|
|
expect(focusedWill).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "critical-chance-percent", amount: 3 }),
|
|
expect.objectContaining({ kind: "damage-taken-percent", amount: -4 }),
|
|
expect.objectContaining({ kind: "healing-percent", amount: 5 }),
|
|
]));
|
|
});
|
|
|
|
it("applies Spell Warding to direct and periodic magic damage, but not physical damage", () => {
|
|
const spellWarding = parseTalentModifiers(
|
|
"Reduces all direct and periodic magic damage taken by 3%.",
|
|
).map((modifier) => ({
|
|
...modifier,
|
|
sourceTalentId: "test:spell-warding",
|
|
sourceTalentName: "Spell Warding",
|
|
rank: 3,
|
|
description: "Reduces all direct and periodic magic damage taken by 3%.",
|
|
}));
|
|
const generatedMaxRank = talentModifiersForRanks({ "talent-411": 3 }, "priest");
|
|
|
|
expect(spellWarding).toContainEqual(expect.objectContaining({
|
|
kind: "damage-taken-percent",
|
|
amount: -3,
|
|
scope: "all direct and periodic magic damage taken",
|
|
}));
|
|
expect(generatedMaxRank).toContainEqual(expect.objectContaining({
|
|
kind: "damage-taken-percent",
|
|
amount: -3,
|
|
sourceTalentId: "talent-411",
|
|
rank: 3,
|
|
}));
|
|
expect(applyTalentModifierValue(100, spellWarding, "damage-taken-percent", {
|
|
tags: ["magic", "shadow"],
|
|
})).toBe(97);
|
|
expect(applyTalentModifierValue(100, spellWarding, "damage-taken-percent", {
|
|
tags: ["magic", "fire", "periodic"],
|
|
})).toBe(97);
|
|
expect(applyTalentModifierValue(100, spellWarding, "damage-taken-percent", {
|
|
tags: ["physical"],
|
|
})).toBe(100);
|
|
});
|
|
|
|
it("scopes Improved Rejuvenation's effect bonus to Rejuvenation healing", () => {
|
|
const improvedRejuvenation = TALENT_NODES.find((node) => node.id === "talent-830")!;
|
|
const modifiers = talentModifiersForRanks({ [improvedRejuvenation.id]: 3 }, "druid");
|
|
|
|
expect(modifiers).toContainEqual(expect.objectContaining({
|
|
kind: "healing-percent",
|
|
amount: 20,
|
|
sourceTalentId: "talent-830",
|
|
rank: 3,
|
|
}));
|
|
expect(applyTalentModifierValue(100, modifiers, "healing-percent", {
|
|
abilityName: "Rejuvenation",
|
|
tags: ["periodic", "healing", "nature"],
|
|
})).toBe(120);
|
|
expect(applyTalentModifierValue(100, modifiers, "healing-percent", {
|
|
abilityName: "Regrowth",
|
|
tags: ["periodic", "healing", "nature"],
|
|
})).toBe(100);
|
|
});
|
|
|
|
it("keeps Warrior named-ability damage and cooldown bonuses on their source abilities", () => {
|
|
const improvedExecute = TALENT_NODES.find((node) => node.name === "Improved Execute")!;
|
|
const executeModifiers = talentModifiersForRanks({ [improvedExecute.id]: 1 }, "warrior");
|
|
expect(applyTalentModifierValue(100, executeModifiers, "damage-percent", { abilityName: "Execute" })).toBe(108);
|
|
expect(applyTalentModifierValue(100, executeModifiers, "damage-percent", { abilityName: "Heroic Strike" })).toBe(100);
|
|
expect(applyTalentModifierValue(15, executeModifiers, "resource-cost-percent", { abilityName: "Heroic Strike" })).toBe(15);
|
|
|
|
const unrelentingAssault = TALENT_NODES.find((node) => node.name === "Unrelenting Assault")!;
|
|
const assaultModifiers = talentModifiersForRanks({ [unrelentingAssault.id]: 1 }, "warrior");
|
|
for (const abilityName of ["Overpower", "Revenge"]) {
|
|
expect(applyTalentModifierValue(100, assaultModifiers, "damage-percent", { abilityName })).toBeCloseTo(110);
|
|
expect(applyTalentModifierValue(10_000, assaultModifiers, "cooldown-flat-ms", { abilityName })).toBe(9_500);
|
|
}
|
|
expect(applyTalentModifierValue(100, assaultModifiers, "damage-percent", { abilityName: "Bloodthirst" })).toBe(100);
|
|
expect(applyTalentModifierValue(10_000, assaultModifiers, "cooldown-flat-ms", { abilityName: "Bloodthirst" })).toBe(10_000);
|
|
|
|
const improvedDisciplines = TALENT_NODES.find((node) => node.name === "Improved Disciplines")!;
|
|
const disciplineModifiers = talentModifiersForRanks({ [improvedDisciplines.id]: 1 }, "warrior");
|
|
expect(applyTalentModifierValue(300_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Shield Wall" })).toBe(290_000);
|
|
expect(applyTalentModifierValue(30_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Retaliation" })).toBe(20_000);
|
|
expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000);
|
|
});
|
|
|
|
it("scopes WoW Mage Impact's rank-scaled damage bonus to Fire Blast and Meteor", () => {
|
|
const impact = TALENT_NODES.find((node) => node.id === "talent-30")!;
|
|
|
|
for (const rank of [1, 2, 3]) {
|
|
const modifiers = talentModifiersForRanks({ [impact.id]: rank }, "mage");
|
|
const expectedBonus = rank * 3;
|
|
expect(modifiers).toContainEqual(expect.objectContaining({
|
|
kind: "damage-percent",
|
|
amount: expectedBonus,
|
|
scope: "damage of your Fire Blast and Meteor abilities",
|
|
}));
|
|
for (const abilityName of ["Fire Blast", "Meteor"]) {
|
|
expect(
|
|
applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName, tags: ["fire"] }),
|
|
`${abilityName} at Impact rank ${rank}`,
|
|
).toBeCloseTo(100 + expectedBonus);
|
|
}
|
|
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
|
|
abilityName: "Fireball", tags: ["fire"],
|
|
})).toBe(100);
|
|
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
|
|
abilityName: "Frostbolt", tags: ["frost"],
|
|
})).toBe(100);
|
|
}
|
|
});
|
|
|
|
it("keeps critical damage bonuses out of ordinary hit damage", () => {
|
|
const modifiers = parseTalentModifiers("Increases the critical damage of your spells by 150%.")
|
|
.map((modifier) => ({ ...modifier, sourceTalentId: "test", sourceTalentName: "Thunderous", rank: 1, description: "" }));
|
|
expect(modifiers).toContainEqual(expect.objectContaining({ kind: "critical-damage-percent", amount: 150 }));
|
|
expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName: "Arm of Thorim" })).toBe(100);
|
|
expect(applyTalentModifierValue(2, modifiers, "critical-damage-percent", { abilityName: "Arm of Thorim" })).toBe(5);
|
|
});
|
|
|
|
it("does not turn Inner Focus activation text into permanent spell modifiers", () => {
|
|
const innerFocus = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
|
|
const modifiers = talentModifiersForRanks({ [innerFocus.id]: 1 }, "priest");
|
|
expect(modifiers).toEqual([]);
|
|
expect(applyTalentModifierValue(2_500, modifiers, "cast-time-percent", {
|
|
abilityName: "Greater Heal",
|
|
tags: ["cast-time", "holy"],
|
|
})).toBe(2_500);
|
|
expect(applyTalentModifierValue(20, modifiers, "resource-cost-percent", {
|
|
abilityName: "Flash Heal",
|
|
tags: ["cast-time", "holy"],
|
|
})).toBe(20);
|
|
});
|
|
|
|
it("keeps Paladin activation and aura text out of persistent modifiers", () => {
|
|
for (const name of ["Aura Mastery", "Divine Favor"]) {
|
|
const talent = TALENT_NODES.find((node) => node.name === name);
|
|
expect(talent, name).toBeDefined();
|
|
expect(talentModifiersForRanks({ [talent!.id]: 1 }, "paladin"), name).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("keeps Frost Warding's Ward and armor clauses out of global scalar modifiers", () => {
|
|
const frostWarding = TALENT_NODES.find((node) => node.id === "talent-70")!;
|
|
const rankTwoDescription = frostWarding.rankDescriptions[1];
|
|
const parsed = parseTalentModifiers(rankTwoDescription);
|
|
expect(parsed).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "armor-percent", amount: 50 }),
|
|
expect.objectContaining({ kind: "damage-taken-percent", amount: -50 }),
|
|
]));
|
|
|
|
const modifiers = talentModifiersForRanks({ [frostWarding.id]: 2 }, "mage");
|
|
expect(modifiers).toEqual([]);
|
|
expect(applyTalentModifierValue(100, modifiers, "armor-percent")).toBe(100);
|
|
expect(applyTalentModifierValue(100, modifiers, "damage-taken-percent", {
|
|
tags: ["magic", "fire"],
|
|
})).toBe(100);
|
|
});
|
|
|
|
it("keeps Paladin Vengeance conditional and keyed separately from Druid Vengeance", () => {
|
|
const paladinVengeance = TALENT_NODES.find((node) => node.id === "talent-1402")!;
|
|
const druidVengeance = TALENT_NODES.find((node) => node.name === "Vengeance" && node.id !== paladinVengeance.id)!;
|
|
expect(talentUsesExplicitActivation(paladinVengeance.name, paladinVengeance.id)).toBe(true);
|
|
expect(talentUsesExplicitActivation(druidVengeance.name, druidVengeance.id)).toBe(false);
|
|
expect(talentModifiersForRanks({ [paladinVengeance.id]: 3 }, "paladin")).toEqual([]);
|
|
expect(talentModifiersForRanks({ [druidVengeance.id]: 1 }, "druid")).not.toContainEqual(
|
|
expect.objectContaining({ kind: "damage-percent", scope: "bonus to Holy damage" }),
|
|
);
|
|
});
|
|
|
|
it("keeps Bestial Discipline pet Focus out of player resource scalars and scopes its Mana rider to Intimidation", () => {
|
|
const bestialDiscipline = TALENT_NODES.find((node) => node.id === "talent-1390")!;
|
|
expect(parseTalentModifiers(bestialDiscipline.rankDescriptions[0])).toEqual([
|
|
expect.objectContaining({
|
|
kind: "resource-cost-percent",
|
|
amount: -25,
|
|
scope: "the mana cost of your Intimidation",
|
|
}),
|
|
]);
|
|
|
|
const modifiers = talentModifiersForRanks({ [bestialDiscipline.id]: 2 }, "hunter");
|
|
expect(modifiers).toHaveLength(1);
|
|
expect(modifiers[0]).toMatchObject({ kind: "resource-cost-percent", amount: -50 });
|
|
expect(applyTalentModifierValue(100, modifiers, "resource-cost-percent", { abilityName: "Intimidation" })).toBe(50);
|
|
expect(applyTalentModifierValue(100, modifiers, "resource-cost-percent", { abilityName: "Arcane Shot" })).toBe(100);
|
|
expect(applyTalentModifierValue(5, modifiers, "resource-regeneration-percent")).toBe(5);
|
|
});
|
|
|
|
it("keeps Illumination's Light's Hammer rider with its critical-heal proc helper", () => {
|
|
const illumination = TALENT_NODES.find((node) => node.id === "talent-1461")!;
|
|
expect(parseTalentModifiers(illumination.rankDescriptions[0])).toContainEqual(
|
|
expect.objectContaining({ kind: "cooldown-flat-ms", amount: -5_000 }),
|
|
);
|
|
expect(talentModifiersForRanks({ [illumination.id]: 1 }, "paladin")).toEqual([]);
|
|
});
|
|
|
|
it("scopes Improved Flash Heal critical chance and keeps its Inner Focus proc out of static cooldowns", () => {
|
|
const improvedFlashHeal = TALENT_NODES.find((node) => node.name === "Improved Flash Heal")!;
|
|
const modifiers = talentModifiersForRanks({ [improvedFlashHeal.id]: 3 }, "priest");
|
|
|
|
expect(applyTalentModifierValue(5, modifiers, "critical-chance-percent", { abilityName: "Flash Heal" })).toBe(20);
|
|
expect(applyTalentModifierValue(5, modifiers, "critical-chance-percent", { abilityName: "Greater Heal" })).toBe(5);
|
|
expect(modifiers.filter((modifier) => modifier.kind === "cooldown-flat-ms")).toEqual([]);
|
|
});
|
|
|
|
it("applies Black Ice to either Frost or Shadow damage", () => {
|
|
const blackIce = TALENT_NODES.find((node) => node.name === "Black Ice")!;
|
|
const modifiers = talentModifiersForRanks({ [blackIce.id]: blackIce.maxRank }, "death-knight");
|
|
|
|
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
|
|
abilityName: "Icy Touch",
|
|
tags: ["damage", "frost"],
|
|
})).toBeCloseTo(110);
|
|
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
|
|
abilityName: "Death Coil",
|
|
tags: ["damage", "shadow"],
|
|
})).toBeCloseTo(110);
|
|
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
|
|
abilityName: "Plague Strike",
|
|
tags: ["damage", "physical"],
|
|
})).toBe(100);
|
|
});
|
|
|
|
it("applies Mage Flame Throwing's generated flat range only to damaging spells", () => {
|
|
for (const [rank, bonus] of [[1, 2], [2, 4], [3, 6]] as const) {
|
|
const modifiers = talentModifiersForRanks({ "talent-28": rank }, "mage");
|
|
expect(applyTalentModifierValue(30, modifiers, "range-flat-yards", {
|
|
abilityName: "Fireball",
|
|
tags: ["cast-time", "damage", "fire"],
|
|
})).toBe(30 + bonus);
|
|
expect(applyTalentModifierValue(30, modifiers, "range-flat-yards", {
|
|
abilityName: "Flash Heal",
|
|
tags: ["cast-time", "healing", "holy"],
|
|
})).toBe(30);
|
|
expect(applyTalentModifierValue(30, talentModifiersForRanks({ "talent-28": rank }, "priest"),
|
|
"range-flat-yards", {
|
|
abilityName: "Smite",
|
|
tags: ["cast-time", "damage", "holy"],
|
|
})).toBe(30);
|
|
}
|
|
});
|
|
});
|