189 lines
7.4 KiB
TypeScript
189 lines
7.4 KiB
TypeScript
import type { AbilityDefinition, AbilityEffect, AbilityRankDefinition } from "./abilityCatalog";
|
|
import type { ActiveAura } from "./combatAuras";
|
|
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
|
|
|
|
const THE_BEAST_WITHIN_NODE_ID = "talent-1803";
|
|
export const WOW335_HUNTER_LOCK_AND_LOAD_TALENT_ID = "talent-1306";
|
|
export const WOW335_HUNTER_ENTRAPMENT_TALENT_ID = "talent-1304";
|
|
export const WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG = "wow335-hunter-lock-and-load-ready";
|
|
export const WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG = "wow335-hunter-lock-and-load-icd";
|
|
|
|
export interface BeastWithinRankMechanics {
|
|
readonly description: string;
|
|
readonly procChance: number;
|
|
readonly additionalDamage: number;
|
|
readonly manaCostReduction: number;
|
|
readonly durationMs: number;
|
|
}
|
|
|
|
export interface LockAndLoadRankMechanics {
|
|
readonly description: string;
|
|
readonly trapProcChance: number;
|
|
readonly periodicProcChance: number;
|
|
readonly freeCastCount: number;
|
|
readonly durationMs: number;
|
|
readonly internalCooldownMs: number;
|
|
}
|
|
|
|
export interface EntrapmentRankMechanics {
|
|
readonly description: string;
|
|
readonly frostTrapSlowBonus: number;
|
|
readonly rootProcChance: number;
|
|
readonly rootDurationMs: number;
|
|
}
|
|
|
|
function tooltipNumber(description: string, pattern: RegExp): number | null {
|
|
const match = description.match(pattern);
|
|
if (!match) return null;
|
|
const value = Number(match[1]);
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
/** Read The Beast Within's mechanics from the generated talent rank text. */
|
|
export function beastWithinRankMechanics(rank: number): BeastWithinRankMechanics | null {
|
|
const node = runtimeTalentNodeById(THE_BEAST_WITHIN_NODE_ID);
|
|
const description = node?.rankDescriptions[Math.trunc(rank) - 1];
|
|
if (!description) return null;
|
|
|
|
const procChance = tooltipNumber(description, /you have a (\d+(?:\.\d+)?)% chance/i);
|
|
const additionalDamage = tooltipNumber(description, /causing (\d+(?:\.\d+)?)% additional damage/i);
|
|
const manaCostReduction = tooltipNumber(description, /reducing mana costs of all spells by (\d+(?:\.\d+)?)%/i);
|
|
const durationSeconds = tooltipNumber(description, /for (\d+(?:\.\d+)?) seconds/i);
|
|
if (procChance === null || additionalDamage === null || manaCostReduction === null || durationSeconds === null) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
description,
|
|
procChance: Math.max(0, Math.min(1, procChance / 100)),
|
|
additionalDamage: additionalDamage / 100,
|
|
manaCostReduction: manaCostReduction / 100,
|
|
durationMs: Math.max(0, Math.round(durationSeconds * 1_000)),
|
|
};
|
|
}
|
|
|
|
/** Read Lock and Load's rank values from its generated tooltip text. */
|
|
export function lockAndLoadRankMechanics(rank: number): LockAndLoadRankMechanics | null {
|
|
const node = runtimeTalentNodeById(WOW335_HUNTER_LOCK_AND_LOAD_TALENT_ID);
|
|
const description = node?.rankDescriptions[Math.trunc(rank) - 1];
|
|
if (!description) return null;
|
|
|
|
const trapProcPercent = tooltipNumber(description, /trapping a target[^.]*?has a (\d+(?:\.\d+)?)% chance/i);
|
|
const periodicProcPercent = tooltipNumber(description, /periodic damage[^.]*?has a (\d+(?:\.\d+)?)% to trigger/i);
|
|
const freeCastCount = tooltipNumber(description, /your next (\d+) Arcane Shot or Explosive Shot/i);
|
|
const cooldownSeconds = tooltipNumber(description, /this effect has a (\d+(?:\.\d+)?) sec cooldown/i);
|
|
if (trapProcPercent === null || periodicProcPercent === null || freeCastCount === null || cooldownSeconds === null) {
|
|
return null;
|
|
}
|
|
|
|
const durationMs = Math.max(0, Math.round(cooldownSeconds * 1_000));
|
|
return {
|
|
description,
|
|
trapProcChance: Math.max(0, Math.min(1, trapProcPercent / 100)),
|
|
periodicProcChance: Math.max(0, Math.min(1, periodicProcPercent / 100)),
|
|
freeCastCount: Math.max(0, Math.trunc(freeCastCount)),
|
|
durationMs,
|
|
internalCooldownMs: durationMs,
|
|
};
|
|
}
|
|
|
|
/** Read the authored Entrapment slow/root values from the generated talent tooltip. */
|
|
export function entrapmentRankMechanics(rank: number): EntrapmentRankMechanics | null {
|
|
const normalizedRank = Math.trunc(rank);
|
|
const node = runtimeTalentNodeById(WOW335_HUNTER_ENTRAPMENT_TALENT_ID);
|
|
const description = node?.rankDescriptions[normalizedRank - 1];
|
|
if (!description) return null;
|
|
|
|
const slowPercent = tooltipNumber(description, /increases the slow effect of Frost Trap by (\d+(?:\.\d+)?)%/i);
|
|
const rootDurationSeconds = tooltipNumber(description, /preventing them from moving for (\d+(?:\.\d+)?) seconds/i);
|
|
if (slowPercent === null || rootDurationSeconds === null) return null;
|
|
|
|
// The local Hunter DBC Entrapment rank rows carry 2/4/6 as their proc base
|
|
// points; their companion child aura supplies the root duration.
|
|
const rootProcChance = [0, 0.02, 0.04, 0.06][normalizedRank];
|
|
if (rootProcChance === undefined) return null;
|
|
return {
|
|
description,
|
|
frostTrapSlowBonus: slowPercent / 100,
|
|
rootProcChance,
|
|
rootDurationMs: Math.max(0, Math.round(rootDurationSeconds * 1_000)),
|
|
};
|
|
}
|
|
|
|
/** Spell.dbc child-aura durations for Freezing Trap's three learned ranks. */
|
|
export function freezingTrapDurationMs(spellId: number): number {
|
|
switch (spellId) {
|
|
case 1499: return 10_000;
|
|
case 14310: return 15_000;
|
|
case 14311:
|
|
case 60192:
|
|
case 60202: return 20_000;
|
|
default: return 20_000;
|
|
}
|
|
}
|
|
|
|
export function consumeLockAndLoadCharge(
|
|
auras: readonly ActiveAura[],
|
|
auraInstanceId: string,
|
|
): readonly ActiveAura[] {
|
|
return auras.flatMap((aura) => aura.instanceId !== auraInstanceId
|
|
? [aura]
|
|
: aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []);
|
|
}
|
|
|
|
function applyBeastWithinRankMechanics(
|
|
effects: readonly AbilityEffect[],
|
|
mechanics: BeastWithinRankMechanics,
|
|
): readonly AbilityEffect[] {
|
|
return effects.map((effect) => {
|
|
if (effect.kind !== "apply-aura") return effect;
|
|
return {
|
|
...effect,
|
|
aura: {
|
|
...effect.aura,
|
|
description: mechanics.description,
|
|
durationMs: mechanics.durationMs,
|
|
modifiers: effect.aura.modifiers?.map((modifier) => {
|
|
if (modifier.kind === "resource" && modifier.property === "cost" && modifier.resource === "power") {
|
|
return { ...modifier, value: -mechanics.manaCostReduction };
|
|
}
|
|
if (modifier.kind === "damage" && modifier.direction === "dealt") {
|
|
return { ...modifier, value: mechanics.additionalDamage };
|
|
}
|
|
return modifier;
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Replace the duplicate DBC child-rank text and payload with the generated talent's actual ranks. */
|
|
export function applyGeneratedBeastWithinTalent(ability: AbilityDefinition): AbilityDefinition {
|
|
if (ability.classId !== "hunter" || ability.name !== "The Beast Within") return ability;
|
|
const node = runtimeTalentNodeById(THE_BEAST_WITHIN_NODE_ID);
|
|
const sourceRank = ability.ranks?.[0];
|
|
if (!node || !sourceRank || node.rankDescriptions.length === 0) return ability;
|
|
|
|
const ranks: AbilityRankDefinition[] = [];
|
|
for (const [index, description] of node.rankDescriptions.entries()) {
|
|
const mechanics = beastWithinRankMechanics(index + 1);
|
|
if (!mechanics) return ability;
|
|
ranks.push({
|
|
...sourceRank,
|
|
rank: index + 1,
|
|
description,
|
|
effects: applyBeastWithinRankMechanics(sourceRank.effects, mechanics),
|
|
});
|
|
}
|
|
|
|
const firstRank = ranks[0];
|
|
if (!firstRank) return ability;
|
|
return {
|
|
...ability,
|
|
talentNodeId: node.id,
|
|
description: firstRank.description,
|
|
effects: firstRank.effects,
|
|
ranks,
|
|
};
|
|
}
|