import type { ClassId } from "../app/characterCatalog"; import { runtimeActiveTalentRankEffects as activeTalentRankEffects, type RuntimeTalentRanks as TalentRanks, } from "./talentRuntimeCatalog"; import { talentBalanceOverride } from "./balanceTuning"; export type TalentModifierKind = | "damage-percent" | "critical-damage-percent" | "healing-percent" | "damage-taken-percent" | "max-health-percent" | "max-resource-percent" | "resource-regeneration-percent" | "resource-cost-percent" | "critical-chance-percent" | "haste-percent" | "cast-time-percent" | "cast-time-flat-ms" | "cooldown-percent" | "cooldown-flat-ms" | "armor-percent" | "attribute-percent" | "range-percent" | "duration-percent" | "threat-percent"; export interface TalentModifier { readonly kind: TalentModifierKind; /** Signed amount: increases are positive and reductions are negative. */ readonly amount: number; readonly unit: "percent" | "milliseconds"; readonly scope: string; readonly attribute?: "agility" | "intellect" | "spirit" | "stamina" | "strength"; readonly sourceTalentId: string; readonly sourceTalentName: string; readonly rank: number; readonly description: string; } interface ParsedModifier extends Omit {} const EXPLICIT_ACTIVATION_TALENTS = new Set([ "Inner Focus", // These effects only exist after a proc or charge is earned; their tooltip // percentages must not be applied as passive stats while inactive. "Holy Concentration", "Serendipity", "Shadow Weaving", // Eradication and Nightfall are conditional proc states, not passive haste, // damage, or resource-cost modifiers from their tooltip text. "Eradication", "Nightfall", // Backlash has passive crit chance plus a conditional next-cast proc. Its // proc damage and cost clauses must not become always-on bonuses. "Backlash", // Aura Mastery and Divine Favor carry scoped next-cast/aura effects. Their // activation tooltips must not be parsed as persistent global modifiers. "Aura Mastery", "Divine Favor", // The Beast Within activates from a pet Bestial Wrath proc; its tooltip // percentages must not become always-on modifiers. "The Beast Within", // These Shaman talents activate temporary effects from combat events; // their conditional percentages are applied by the Shaman runtime. "Elemental Focus", "Reverberation", "Maelstrom Weapon", ]); /** * These talents have activation or proc behavior and must never be interpreted * as always-on scalar bonuses merely because their tooltip contains phrases * such as "reduces cast time" or "reducing mana cost". */ export function talentUsesExplicitActivation(talentName: string): boolean { return EXPLICIT_ACTIVATION_TALENTS.has(talentName); } /** * Converts the concrete client tooltip into common simulation modifiers. * Exact DBC effects remain available through the full authoring catalog for * talents whose proc/trigger behavior cannot be represented as a scalar. */ export function parseTalentModifiers(description: string): readonly ParsedModifier[] { const modifiers: ParsedModifier[] = []; const normalizedDescription = description.replace( /([a-z0-9])(?=(?:increase(?:s|d)?|increasing|reduce(?:s|d)?|reducing|decrease(?:s|d)?|decreasing)\b)/gi, "$1 ", ); const add = (modifier: ParsedModifier) => { const duplicate = modifiers.some((candidate) => candidate.kind === modifier.kind && candidate.amount === modifier.amount && candidate.scope === modifier.scope && candidate.attribute === modifier.attribute); if (!duplicate && Number.isFinite(modifier.amount) && modifier.amount !== 0) modifiers.push(modifier); }; const addPercentCategories = (scope: string, amount: number) => { const lower = scope.toLowerCase(); if (/(?:maximum|total) health/.test(lower)) add({ kind: "max-health-percent", amount, unit: "percent", scope }); if (/maximum mana|maximum (?:rage|energy|runic power)|maximum health and mana/.test(lower)) add({ kind: "max-resource-percent", amount, unit: "percent", scope }); if (/mana regeneration|regenerate mana|resource regeneration/.test(lower)) add({ kind: "resource-regeneration-percent", amount, unit: "percent", scope }); if (/(?:mana|rage|energy|runic power) cost/.test(lower)) add({ kind: "resource-cost-percent", amount, unit: "percent", scope }); if (/critical (?:effect |strike )?chance|chance to critically/.test(lower)) add({ kind: "critical-chance-percent", amount, unit: "percent", scope }); if (/\bhaste\b|casting speed/.test(lower)) add({ kind: "haste-percent", amount, unit: "percent", scope }); if (/casting time|cast time/.test(lower)) add({ kind: "cast-time-percent", amount, unit: "percent", scope }); if (/cooldown/.test(lower)) add({ kind: "cooldown-percent", amount, unit: "percent", scope }); if (/damage (?:done )?to you|damage you (?:take|suffer)|damage taken/.test(lower)) { add({ kind: "damage-taken-percent", amount, unit: "percent", scope }); } else if (/\bcritical damage\b/.test(lower)) { add({ kind: "critical-damage-percent", amount, unit: "percent", scope }); } else if (/\bdamage\b/.test(lower)) { add({ kind: "damage-percent", amount, unit: "percent", scope }); } if (/\bhealing\b|heals? (?:done|cast)/.test(lower)) add({ kind: "healing-percent", amount, unit: "percent", scope }); // The generated Druid tooltip for Improved Rejuvenation describes its // healing as an increase to the spell's "effect" rather than naming heal. if (/\beffect of your rejuvenation spell\b/.test(lower)) { add({ kind: "healing-percent", amount, unit: "percent", scope }); } if (/\barmor\b/.test(lower)) add({ kind: "armor-percent", amount, unit: "percent", scope }); if (/\brange\b/.test(lower)) add({ kind: "range-percent", amount, unit: "percent", scope }); if (/\bduration\b|lasts? longer/.test(lower)) add({ kind: "duration-percent", amount, unit: "percent", scope }); if (/\bthreat\b/.test(lower)) add({ kind: "threat-percent", amount, unit: "percent", scope }); const attributeScope = lower .replace(/\b(?:your|total)\b/g, "") .replace(/\s+/g, " ") .trim(); if (!/^(?:agility|intellect|spirit|stamina|strength)(?:\s*(?:,|and)\s*(?:agility|intellect|spirit|stamina|strength))*$/.test(attributeScope)) { return; } for (const attribute of ["agility", "intellect", "spirit", "stamina", "strength"] as const) { if (new RegExp(`\\b${attribute}\\b`).test(attributeScope)) { add({ kind: "attribute-percent", amount, unit: "percent", scope, attribute }); } } }; for (const sentence of normalizedDescription.split(/(?<=[.!?])\s+/)) { const modifierCountBeforeSentence = modifiers.length; const active = /\b(increase(?:s|d)?|increasing|reduce(?:s|d)?|reducing|decrease(?:s|d)?|decreasing)\s+([^,.!?]{1,180}?)\s+by\s+(-?\d+(?:\.\d+)?)%/gi; for (const match of sentence.matchAll(active)) { const direction = /^(?:reduc|decreas)/i.test(match[1]) ? -1 : 1; addPercentCategories(match[2], direction * Math.abs(Number(match[3]))); } const passive = /([^,.!?]{1,180}?)\s+(?:is|are)\s+(increased|reduced|decreased)\s+by\s+(-?\d+(?:\.\d+)?)%/gi; for (const match of sentence.matchAll(passive)) { const direction = /^(?:reduced|decreased)$/i.test(match[2]) ? -1 : 1; addPercentCategories(match[1], direction * Math.abs(Number(match[3]))); } // Lists of named spells contain commas, which intentionally bound the // clause parser above. Fall back to the complete sentence only when no // narrower clause was recognized. if (modifiers.length === modifierCountBeforeSentence) { const fallback = sentence.match(/\b(increase(?:s|d)?|increasing|reduce(?:s|d)?|reducing|decrease(?:s|d)?|decreasing).*?\bby\s+(-?\d+(?:\.\d+)?)%/i); if (fallback) { const direction = /^(?:reduc|decreas)/i.test(fallback[1]) ? -1 : 1; addPercentCategories(sentence, direction * Math.abs(Number(fallback[2]))); } } const castTime = sentence.match(/reduces? (?:the )?(?:casting|cast) time (?:of |for )?.*? by (\d+(?:\.\d+)?) sec/i); 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 }); } return modifiers; } export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): readonly TalentModifier[] { return activeTalentRankEffects(ranks, classId).flatMap((active) => { if (active.talentName === "Backlash") { return [{ kind: "critical-chance-percent" as const, amount: active.rank, unit: "percent" as const, scope: "your spells and abilities", sourceTalentId: active.talentId, sourceTalentName: active.talentName, rank: active.rank, description: active.description, }]; } if (talentUsesExplicitActivation(active.talentName)) return []; // Its healing and damage bonuses only apply below a target-health threshold; // the scalar parser cannot encode that condition. CombatStore applies the // rank-aware bonus at the individual target instead. if (active.talentName === "Test of Faith") return []; const override = talentBalanceOverride(active.talentId); const scale = (override?.effectScalePercent ?? 100) / 100; const parsed = parseTalentModifiers(active.description) // Improved Shadow Bolt's 5% spell crit applies to the debuffed target; // only its named Shadow Bolt damage bonus is a persistent caster modifier. .filter((modifier) => active.talentName !== "Improved Shadow Bolt" || modifier.kind !== "critical-chance-percent") .filter((modifier) => !(classId === "warrior" && active.talentName === "Unrelenting Assault" && ["damage-percent", "cooldown-percent"].includes(modifier.kind))) .flatMap((modifier) => { if (classId === "warrior" && active.talentName === "Improved Execute") { // This tooltip combines a flat Rage reduction, damage percent, and a // conditional capstone crit. The broad parser merges the first two // clauses, so rebuild only the source-backed damage component here; // combatStore applies the flat Rage reduction when casting Execute. if (["resource-cost-percent", "damage-percent", "critical-chance-percent"].includes(modifier.kind)) { if (modifier.kind !== "damage-percent") return []; const increase = active.description.match(/increases its damage by (\d+(?:\.\d+)?)%/i); if (!increase) return []; return [{ ...modifier, amount: Number(increase[1]) * 2 * scale, scope: "damage of your Execute ability", sourceTalentId: active.talentId, sourceTalentName: active.talentName, rank: active.rank, description: active.description, }]; } } return [{ ...modifier, amount: modifier.amount * scale, sourceTalentId: active.talentId, sourceTalentName: active.talentName, rank: active.rank, description: active.description, }]; }); const unrelentingAssaultDamage = classId === "warrior" && active.talentName === "Unrelenting Assault" ? active.description.match(/increases the damage done by both abilities by (\d+(?:\.\d+)?)%/i) : null; const explicitScoped = unrelentingAssaultDamage ? [{ kind: "damage-percent" as const, amount: Number(unrelentingAssaultDamage[1]) * scale, unit: "percent" as const, scope: "damage of your Overpower and Revenge abilities", sourceTalentId: active.talentId, sourceTalentName: active.talentName, rank: active.rank, description: active.description, }] : []; const supplemental = Object.entries(override?.modifiersPerRank ?? {}).flatMap(([kind, amount]) => { if (!Number.isFinite(amount) || amount === 0) return []; return [{ kind: kind as TalentModifierKind, amount: amount * active.rank, unit: "percent" as const, scope: `Balance tuning supplement for ${active.talentName}`, sourceTalentId: active.talentId, sourceTalentName: active.talentName, rank: active.rank, description: active.description, }]; }); return [...parsed, ...explicitScoped, ...supplemental]; }); } export interface TalentModifierContext { readonly abilityName?: string; readonly tags?: readonly string[]; } function modifierApplies(modifier: TalentModifier, context: TalentModifierContext): boolean { const scope = modifier.scope.toLowerCase(); const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase())); if (scope.includes("instant") && !tags.has("instant")) return false; if (scope.includes("periodic") && !tags.has("periodic")) return false; const schools = (["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) .filter((school) => scope.includes(school)); if (schools.length > 1) { if (!schools.some((school) => tags.has(school))) return false; } else if (schools.length === 1 && !tags.has(schools[0])) { return false; } if (context.abilityName && /\b(?:spell|ability)\b/.test(scope)) { const normalizedName = context.abilityName.toLowerCase(); const namedScope = scope.match(/(?:spell|ability)\s+([a-z ':-]+)/)?.[1]?.trim(); if (namedScope && !namedScope.includes("your") && !scope.includes(normalizedName)) return false; } if (context.abilityName) { const normalizedName = context.abilityName.toLowerCase(); 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); const namedAbilityList = genericScope ? undefined : scope.match(/\byour\s+(.+?)\s+abilit(?:y|ies)\b/)?.[1] ?.split(/\s*(?:,|\band\b|\bor\b)\s*/) .map((name) => name.trim()); if (namedAbilityList?.length && !namedAbilityList.includes(normalizedName)) return false; if (namedScope && !genericScope && !scope.includes(normalizedName)) return false; } return true; } /** Applies all matching scalar modifiers to a base stat or ability value. */ export function applyTalentModifierValue( baseValue: number, modifiers: readonly TalentModifier[], kind: TalentModifierKind, context: TalentModifierContext = {}, ): number { const matching = modifiers.filter((modifier) => modifier.kind === kind && modifierApplies(modifier, context)); if (kind === "critical-chance-percent") { return baseValue + matching.reduce((total, modifier) => total + modifier.amount, 0); } if (kind.endsWith("-percent")) { return baseValue * (1 + matching.reduce((total, modifier) => total + modifier.amount, 0) / 100); } return baseValue + matching.reduce((total, modifier) => total + modifier.amount, 0); }