diff --git a/src/game/talentModifiers.test.ts b/src/game/talentModifiers.test.ts index f7803fa2..c55b8a37 100644 --- a/src/game/talentModifiers.test.ts +++ b/src/game/talentModifiers.test.ts @@ -174,6 +174,52 @@ describe("talent runtime modifiers", () => { expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000); }); + it("keeps Morbidity's Death Coil clauses and Death and Decay cooldown on their named spells", () => { + const morbidity = TALENT_NODES.find((node) => node.id === "talent-1933")!; + expect(morbidity.name).toBe("Morbidity"); + expect(morbidity.rankDescriptions[2]).toBe( + "Increases the damage and healing of Death Coil by 15% and reduces the cooldown on Death and Decay by 15 sec.", + ); + + const modifiers = talentModifiersForRanks({ [morbidity.id]: 3 }, "death-knight"); + expect(modifiers).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "damage-percent", amount: 15 }), + expect.objectContaining({ kind: "healing-percent", amount: 15 }), + expect.objectContaining({ kind: "cooldown-flat-ms", amount: -15_000, scope: "cooldown on Death and Decay" }), + ])); + + expect(applyTalentModifierValue(30_000, modifiers, "cooldown-flat-ms", { + abilityName: "Death and Decay", + })).toBe(15_000); + for (const [abilityName, baseCooldownMs] of [ + ["Dark Command", 8_000], + ["Death Grip", 35_000], + ["Death Coil", 8_000], + ] as const) { + expect( + applyTalentModifierValue(baseCooldownMs, modifiers, "cooldown-flat-ms", { abilityName }), + abilityName, + ).toBe(baseCooldownMs); + } + + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName: "Death Coil" })).toBeCloseTo(115); + expect(applyTalentModifierValue(100, modifiers, "healing-percent", { abilityName: "Death Coil" })).toBeCloseTo(115); + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName: "Icy Touch" })).toBe(100); + expect(applyTalentModifierValue(100, modifiers, "healing-percent", { abilityName: "Death Strike" })).toBe(100); + + const broadCooldown = parseTalentModifiers("Reduces the cooldown of all your spells by 2 sec.") + .map((modifier) => ({ + ...modifier, + sourceTalentId: "test:broad-cooldown", + sourceTalentName: "Broad Cooldown", + rank: 1, + description: "Reduces the cooldown of all your spells by 2 sec.", + })); + for (const abilityName of ["Fireball", "Icy Touch"]) { + expect(applyTalentModifierValue(10_000, broadCooldown, "cooldown-flat-ms", { abilityName })).toBe(8_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")!; diff --git a/src/game/talentModifiers.ts b/src/game/talentModifiers.ts index 4a17bdd4..7b19011f 100644 --- a/src/game/talentModifiers.ts +++ b/src/game/talentModifiers.ts @@ -223,8 +223,12 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi if (castTime) add({ kind: "cast-time-flat-ms", amount: -Number(castTime[1]) * 1000, unit: "milliseconds", scope: sentence }); const cooldown = /\bcasts?\s+reduce(?:s)?\s+(?:the\s+)?cooldown\b/i.test(sentence) ? null - : sentence.match(/reduces? (?:the )?cooldown (?:of |on )?.*? by (\d+(?:\.\d+)?) sec/i); - if (cooldown) add({ kind: "cooldown-flat-ms", amount: -Number(cooldown[1]) * 1000, unit: "milliseconds", scope: sentence }); + : sentence.match(/\breduces?\s+(?:the\s+)?cooldown(?:\s+(of|on)\s+(.+?))?\s+by\s+(\d+(?:\.\d+)?)\s+secs?\b/i); + if (cooldown) { + const target = cooldown[2]?.trim(); + const scope = target ? `cooldown ${cooldown[1]} ${target}` : "cooldown"; + add({ kind: "cooldown-flat-ms", amount: -Number(cooldown[3]) * 1000, unit: "milliseconds", scope }); + } } return modifiers; @@ -445,6 +449,20 @@ export interface TalentModifierContext { readonly tags?: readonly string[]; } +function namedModifierTarget(scope: string): string | undefined { + const scopeWithoutAmount = scope.replace(/\s+by\s+-?\d+(?:\.\d+)?\s*(?:%|secs?)(?:\s*[.!?])?$/i, ""); + const target = scopeWithoutAmount.match(/\b(?:of|on|with|for|to)\s+(?:your\s+)?([^,.!?]+)$/)?.[1]?.trim(); + if (!target) return undefined; + + // Generic descriptors describe a category of spells, effects, or targets. + // Keep those modifiers broad; remaining words indicate a named ability. + const remainingTarget = target.toLowerCase() + .replace(/\b(?:a|an|the|your|all|any|each|every|both|other|next|additional|total|maximum|direct|periodic|instant|damaging|healing|helpful|harmful|melee|ranged|magic|physical|arcane|fire|frost|holy|nature|shadow|spell|spells|ability|abilities|attack|attacks|damage|cooldown|cost|cast|casting|time|effect|effects|critical|strike|chance|done|dealt|taken|suffered|received|target|targets|enemy|enemies|ally|allies|friendly|hostile|party|raid|members|you|self|both|and|or|of|on|with|for|to|by)\b/g, " ") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + return remainingTarget ? target.toLowerCase() : undefined; +} + function modifierApplies(modifier: TalentModifier, context: TalentModifierContext): boolean { const scope = modifier.scope.toLowerCase(); const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase())); @@ -469,6 +487,8 @@ function modifierApplies(modifier: TalentModifier, context: TalentModifierContex } if (context.abilityName) { const normalizedName = context.abilityName.toLowerCase(); + const namedTarget = namedModifierTarget(scope); + if (namedTarget && !namedTarget.includes(normalizedName)) return false; const namedScope = /(?:damage|healing|cost|cooldown|cast(?:ing)? time|effect|critical (?:effect |strike )?chance|chance to critically).*?\b(?:by|of|with) your\b/.test(scope); const genericScope = /\byour (?:all |instant |periodic |healing |damage |helpful |harmful )*(?:spells?|abilities?|attacks?)\b/.test(scope) || /\byour (?:arcane|fire|frost|holy|nature|shadow) spells?\b/.test(scope);