Fix named talent cooldown scopes (#20)

Preserve named cooldown targets from tooltip clauses and keep named damage and healing effects scoped to their described abilities. Broad spell cooldowns remain broad.

Issue: #20
Coordinator: Root (/root), session 01a12399-cfab-7c72-8455-e0e4cb7e156c
Worker: /root/combat_talents_luna, GPT-6 Luna xhigh
Branch: fix/20-named-talent-scope
This commit is contained in:
warren authored and Codex (Root combat coordinator) committed 2026-10-15 09:10:55 -04:00
1 parent 72a67ac1e3
commit b3e0445e9c
2 files changed
+68 -2

No files matched your search

+46
View File
@@ -174,6 +174,52 @@ describe("talent runtime modifiers", () => {
expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000); 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", () => { 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")!; const impact = TALENT_NODES.find((node) => node.id === "talent-30")!;
+22 -2
View File
@@ -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 }); 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) const cooldown = /\bcasts?\s+reduce(?:s)?\s+(?:the\s+)?cooldown\b/i.test(sentence)
? null ? null
: sentence.match(/reduces? (?:the )?cooldown (?:of |on )?.*? by (\d+(?:\.\d+)?) sec/i); : sentence.match(/\breduces?\s+(?:the\s+)?cooldown(?:\s+(of|on)\s+(.+?))?\s+by\s+(\d+(?:\.\d+)?)\s+secs?\b/i);
if (cooldown) add({ kind: "cooldown-flat-ms", amount: -Number(cooldown[1]) * 1000, unit: "milliseconds", scope: sentence }); 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; return modifiers;
@@ -445,6 +449,20 @@ export interface TalentModifierContext {
readonly tags?: readonly string[]; 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 { function modifierApplies(modifier: TalentModifier, context: TalentModifierContext): boolean {
const scope = modifier.scope.toLowerCase(); const scope = modifier.scope.toLowerCase();
const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase())); const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase()));
@@ -469,6 +487,8 @@ function modifierApplies(modifier: TalentModifier, context: TalentModifierContex
} }
if (context.abilityName) { if (context.abilityName) {
const normalizedName = context.abilityName.toLowerCase(); 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 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) 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); || /\byour (?:arcane|fire|frost|holy|nature|shadow) spells?\b/.test(scope);