import type { ClassId } from "../app/characterCatalog"; import { runtimeActiveTalentRankEffects as activeTalentRankEffects, type RuntimeTalentRanks as TalentRanks, } from "./talentRuntimeCatalog"; 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 {} /** * 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 }); 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 = 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) => parseTalentModifiers(active.description).map((modifier) => ({ ...modifier, sourceTalentId: active.talentId, sourceTalentName: active.talentName, rank: active.rank, description: active.description, }))); } 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; for (const school of ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) { if (scope.includes(school) && !tags.has(school)) 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 namedScope = /(?:damage|healing|cost|cooldown|cast(?:ing)? time).*?\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); if (namedScope && !genericScope && !scope.includes(context.abilityName.toLowerCase())) 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); }