Files
healer-man/src/game/talentModifiers.ts
T
2026-09-13 13:21:27 -04:00

183 lines
9.2 KiB
TypeScript

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<TalentModifier, "sourceTalentId" | "sourceTalentName" | "rank" | "description"> {}
/**
* 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);
}