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: phenom/healer-man#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

+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 });
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);