forked from phenom/healer-man
502 lines
25 KiB
TypeScript
502 lines
25 KiB
TypeScript
import type { ClassId } from "../app/characterCatalog";
|
|
import {
|
|
runtimeActiveTalentRankEffects as activeTalentRankEffects,
|
|
type RuntimeTalentRanks as TalentRanks,
|
|
} from "./talentRuntimeCatalog";
|
|
import { talentBalanceOverride } from "./balanceTuning";
|
|
import { wow335MageFlameThrowingProfile } from "./wow335MageFlameThrowingRuntime";
|
|
import {
|
|
WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID,
|
|
wow335MageImprovedFireBlastCooldownReductionMs,
|
|
wow335MageImprovedFireBlastDamageBonusPercent,
|
|
} from "./wow335MageImprovedFireBlastRuntime";
|
|
import { wow335DruidTalentModifierOverrides } from "./wow335DruidTalentRuntime";
|
|
import { WOW335_DRUID_PREDATORY_INSTINCTS_TALENT_ID } from "./wow335DruidPredatoryInstinctsRuntime";
|
|
import { wow335ShamanConvectionModifiersForRank } from "./wow335ShamanTalentRuntime";
|
|
import {
|
|
WOW335_SHAMAN_ELEMENTAL_WARDING_TALENT_ID,
|
|
wow335ShamanElementalWardingModifierForRank,
|
|
} from "./wow335ShamanElementalWardingRuntime";
|
|
|
|
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"
|
|
| "range-flat-yards"
|
|
| "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" | "yards";
|
|
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"> {}
|
|
|
|
const EXPLICIT_ACTIVATION_TALENTS = new Set([
|
|
"Inner Focus",
|
|
// Arcane Potency exists only after Presence of Mind or Arcane Concentration
|
|
// Clearcasting. The generated next-spell crit text is applied by its proc aura.
|
|
"Arcane Potency",
|
|
// 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",
|
|
// Backdraft's cast-time/GCD reduction applies only to its temporary three-cast proc.
|
|
"Backdraft",
|
|
// Brain Freeze mixes a conditional next Fireball/Frostfire Bolt proc with
|
|
// separate school and target riders; the proc bonus is applied at cast time.
|
|
"Brain Freeze",
|
|
// Netherwind Presence haste exists only on eligible spell-triggered stacks;
|
|
// the helper/runtime applies that temporary state instead of parsing it as passive haste.
|
|
"Netherwind Presence",
|
|
// 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",
|
|
// Redoubt's Strength and damage-reduction clauses only exist during its
|
|
// block-triggered ten-second proc; the shield block chance is handled by
|
|
// the Paladin runtime with an equipped-shield check.
|
|
"Redoubt",
|
|
// 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",
|
|
// The melee/ranged critical-strike rider only exists after an elemental
|
|
// spell critical; the Shaman runtime applies it as a timed aura.
|
|
"Elemental Devastation",
|
|
"Reverberation",
|
|
"Maelstrom Weapon",
|
|
// Tidal Waves only affects casts while its earned charge aura is active.
|
|
"Tidal Waves",
|
|
// Convection's comma-delimited named-spell list is represented by explicit
|
|
// per-ability scopes so the generic parser cannot truncate it at Shock.
|
|
"Convection",
|
|
// Riptide's 25% rider only applies to the next Chain Heal on its HoT target.
|
|
"Riptide",
|
|
// Frost Warding combines an armor/resistance modifier with a Ward-only
|
|
// damage proc. Both clauses need rank/aura/event scoping in its runtime.
|
|
"Frost Warding",
|
|
// Illumination's cooldown clause is paired with a critical-heal Mana proc;
|
|
// its exact rank behavior belongs to the dedicated event helper.
|
|
"Illumination",
|
|
// Torment the Weak applies only to named damage spells and only against a
|
|
// target that is currently slowed; its conditional part is target-aware.
|
|
"Torment the Weak",
|
|
// Feral Instinct has one named damage bonus plus two cast-state clauses;
|
|
// only the named Swipe/Ravage clause is a persistent scalar modifier.
|
|
"Feral Instinct",
|
|
// Master Shapeshifter has four separate form-specific bonuses; the active
|
|
// shapeshift decides which single clause the Druid runtime can apply.
|
|
"Master Shapeshifter",
|
|
// Paladin Vengeance is keyed by talent ID below because Druid has a distinct
|
|
// same-named talent with different behavior.
|
|
"talent-1402",
|
|
]);
|
|
|
|
/**
|
|
* 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, talentId?: string): boolean {
|
|
return EXPLICIT_ACTIVATION_TALENTS.has(talentName)
|
|
|| (talentId !== undefined && EXPLICIT_ACTIVATION_TALENTS.has(talentId));
|
|
}
|
|
|
|
/**
|
|
* 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 (classId === "druid" && active.talentId === "talent-799") {
|
|
// Do not parse Tiger's Fury and Enrage's conditional bonuses as global
|
|
// damage dealt/taken modifiers. Their temporary aura states are applied
|
|
// by the Druid cast runtime.
|
|
return wow335DruidTalentModifierOverrides(active.talentId, active.rank, active.description);
|
|
}
|
|
if (classId === "druid" && active.talentId === WOW335_DRUID_PREDATORY_INSTINCTS_TALENT_ID) {
|
|
// Both clauses are gated by Cat Form; the AoE rider also needs authored
|
|
// attack delivery metadata, so the generic scalar parser cannot apply them.
|
|
return [];
|
|
}
|
|
// Elemental Oath's generated rank text repeats one crit bonus for the
|
|
// owner and the party. CombatStore applies the rank once to each spell
|
|
// attack/heal source, so the broad scalar parser must not apply it again.
|
|
if (classId === "shaman" && active.talentId === "talent-2049") return [];
|
|
if (classId === "shaman" && active.talentId === WOW335_SHAMAN_ELEMENTAL_WARDING_TALENT_ID) {
|
|
const modifier = wow335ShamanElementalWardingModifierForRank(active.rank);
|
|
return modifier ? [{
|
|
...modifier,
|
|
sourceTalentId: active.talentId,
|
|
sourceTalentName: active.talentName,
|
|
rank: active.rank,
|
|
description: active.description,
|
|
}] : [];
|
|
}
|
|
if (classId === "shaman" && active.talentId === "talent-564") {
|
|
return wow335ShamanConvectionModifiersForRank(active.rank).map((modifier) => ({
|
|
...modifier,
|
|
sourceTalentId: active.talentId,
|
|
sourceTalentName: active.talentName,
|
|
rank: active.rank,
|
|
description: active.description,
|
|
}));
|
|
}
|
|
if (classId === "mage" && active.talentId === "talent-28") {
|
|
const amount = wow335MageFlameThrowingProfile(active.rank).damagingSpellRangeBonusYards;
|
|
return amount > 0 ? [{
|
|
kind: "range-flat-yards" as const,
|
|
amount,
|
|
unit: "yards" as const,
|
|
scope: "your damaging spells",
|
|
sourceTalentId: active.talentId,
|
|
sourceTalentName: active.talentName,
|
|
rank: active.rank,
|
|
description: active.description,
|
|
}] : [];
|
|
}
|
|
if (classId === "mage" && active.talentId === WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID) {
|
|
const cooldownReductionMs = wow335MageImprovedFireBlastCooldownReductionMs(active.rank, "Fire Blast");
|
|
const damageBonusPercent = wow335MageImprovedFireBlastDamageBonusPercent(active.rank);
|
|
return [
|
|
...(cooldownReductionMs > 0 ? [{
|
|
kind: "cooldown-flat-ms" as const,
|
|
amount: -cooldownReductionMs,
|
|
unit: "milliseconds" as const,
|
|
scope: "cooldown of your Fire Blast spell",
|
|
}] : []),
|
|
...(damageBonusPercent > 0 ? [{
|
|
kind: "damage-percent" as const,
|
|
amount: damageBonusPercent,
|
|
unit: "percent" as const,
|
|
scope: "damage of your Fire Blast",
|
|
}] : []),
|
|
].map((modifier) => ({
|
|
...modifier,
|
|
sourceTalentId: active.talentId,
|
|
sourceTalentName: active.talentName,
|
|
rank: active.rank,
|
|
description: active.description,
|
|
}));
|
|
}
|
|
// Molten Fury is active only while the individual target is below 35%
|
|
// health. The target-health predicate is applied by the Mage damage path,
|
|
// so keep its generated rank amount out of always-on scalar modifiers.
|
|
if (classId === "mage" && active.talentId === "talent-1732") return [];
|
|
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 (active.talentName === "Backdraft") {
|
|
const shadowburnDamage = active.description.match(/increases the damage of your Shadowburn by (\d+(?:\.\d+)?)%/i);
|
|
if (!shadowburnDamage) return [];
|
|
return [{
|
|
kind: "damage-percent" as const,
|
|
amount: Number(shadowburnDamage[1]),
|
|
unit: "percent" as const,
|
|
scope: "damage of your Shadowburn",
|
|
sourceTalentId: active.talentId,
|
|
sourceTalentName: active.talentName,
|
|
rank: active.rank,
|
|
description: active.description,
|
|
}];
|
|
}
|
|
if (talentUsesExplicitActivation(active.talentName, active.talentId)) 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 druidOverrides = classId === "druid"
|
|
? wow335DruidTalentModifierOverrides(active.talentId, active.rank, active.description)
|
|
: [];
|
|
const overriddenKinds = new Set(druidOverrides.map((modifier) => modifier.kind));
|
|
const parsed = parseTalentModifiers(active.description)
|
|
.filter((modifier) => !overriddenKinds.has(modifier.kind))
|
|
// Empowered Fire's generated tooltip names the three affected spells,
|
|
// but this effect belongs to the caster. Combat applies it after
|
|
// resolving the source actor so party Mage ranks cannot leak from the
|
|
// player's scalar modifier list.
|
|
.filter((modifier) => !(classId === "mage" && active.talentId === "talent-1734"
|
|
&& modifier.kind === "damage-percent"))
|
|
// Focused Will's healing-received rider only exists after taking a
|
|
// critical hit. Preserve the rank's passive spell-crit modifier above,
|
|
// while the conditional +3% healing stacks come from its runtime aura.
|
|
.filter((modifier) => !(classId === "priest" && active.talentId === "talent-1858"
|
|
&& modifier.kind === "healing-percent"))
|
|
// Impact's passive bonus is restricted to Fire Blast and Meteor. The
|
|
// generated tooltip names those abilities without saying "your", so the
|
|
// general scope parser would otherwise treat the damage bonus as global.
|
|
.filter((modifier) => !(classId === "mage" && active.talentId === "talent-30"
|
|
&& modifier.kind === "damage-percent"))
|
|
// 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 mageImpactDamage = classId === "mage" && active.talentId === "talent-30"
|
|
? active.description.match(/increases the damage of Fire Blast and Meteor by (\d+(?:\.\d+)?)%/i)
|
|
: null;
|
|
const explicitMageImpact = mageImpactDamage ? [{
|
|
kind: "damage-percent" as const,
|
|
amount: Number(mageImpactDamage[1]) * scale,
|
|
unit: "percent" as const,
|
|
scope: "damage of your Fire Blast and Meteor abilities",
|
|
sourceTalentId: active.talentId,
|
|
sourceTalentName: active.talentName,
|
|
rank: active.rank,
|
|
description: active.description,
|
|
}] : [];
|
|
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, ...explicitMageImpact, ...explicitScoped, ...druidOverrides, ...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("damaging spell") && !tags.has("damage")) return false;
|
|
if (scope.includes("with a channeling or cast time")
|
|
&& !tags.has("cast-time") && !tags.has("channel")) return false;
|
|
if (scope.includes("instant") && !tags.has("instant")) return false;
|
|
const coversDirectAndPeriodic = /\bdirect and periodic\b/.test(scope);
|
|
if (scope.includes("periodic") && !coversDirectAndPeriodic && !tags.has("periodic")) return false;
|
|
if (scope.includes("magic damage") && !tags.has("magic")) 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);
|
|
}
|