import { runtimeTalentNodeById } from "./talentRuntimeCatalog"; export const WOW335_HUNTER_GO_FOR_THE_THROAT_TALENT_ID = "talent-1818"; export const WOW335_HUNTER_GO_FOR_THE_THROAT_INTERNAL_COOLDOWN_MS = 2_000; const HUNTER_GO_FOR_THE_THROAT_DIRECT_ATTACK_KINDS: Readonly> = Object.freeze({ "wow335-hunter-aimed-shot": "ranged", "wow335-hunter-arcane-shot": "ranged", "wow335-hunter-auto-shot": "ranged", "wow335-hunter-chimera-shot": "ranged", "wow335-hunter-counterattack": "melee", "wow335-hunter-kill-shot": "ranged", "wow335-hunter-mongoose-bite": "melee", "wow335-hunter-multi-shot": "ranged", "wow335-hunter-raptor-strike": "melee", "wow335-hunter-scatter-shot": "ranged", "wow335-hunter-silencing-shot": "ranged", "wow335-hunter-steady-shot": "ranged", // Simulated Hunter weapon swings use these runtime event IDs rather than // the Auto Shot spell row; the player path currently reports its generic // basic swing as melee, while party Hunters report their basic swing ranged. "party-auto-attack": "ranged", "player-auto-attack": "melee", "player-auto-attack-offhand": "melee", // The core executable catalog keeps these two seeded actions' stable IDs // when their local Spell.dbc rank rows are merged. "hunter-arcane-shot": "ranged", "hunter-steady-shot": "ranged", }); /** * Classifies only local generated Hunter rows whose descriptions/effects name * a direct melee strike or ranged shot. Trap, sting, and channel rows are not * inferred to be weapon attacks from their damage school or range alone. */ export function wow335HunterGoForTheThroatAttackKindForAbility( abilityId: string, ): "melee" | "ranged" | null { return HUNTER_GO_FOR_THE_THROAT_DIRECT_ATTACK_KINDS[abilityId] ?? null; } export interface Wow335HunterGoForTheThroatRankMechanics { readonly description: string; readonly focusPerProc: number; readonly internalCooldownMs: number; } export interface Wow335HunterGoForTheThroatProcEvent { readonly attackType: "melee" | "ranged" | "other"; readonly critical: boolean; readonly hasLivingActivePet: boolean; readonly focusCurrent: number; readonly focusMaximum: number; readonly lastProcAt: number | null; readonly now: number; } export interface Wow335HunterGoForTheThroatProcPlan { readonly focusGain: number; readonly focusAfter: number; readonly lastProcAt: number; } const talent = runtimeTalentNodeById(WOW335_HUNTER_GO_FOR_THE_THROAT_TALENT_ID); /** * Reads the Focus payload from the generated 3.3.5a talent rank text. * The generated ranks say 15/30 Focus and share a two-second proc lockout. */ export function wow335HunterGoForTheThroatRankMechanics( rank: number, ): Wow335HunterGoForTheThroatRankMechanics | null { const normalizedRank = Math.trunc(Number.isFinite(rank) ? rank : 0); const description = talent?.rankDescriptions[normalizedRank - 1]; if (!description) return null; const focusMatch = description.match(/tamed beast to generate (\d+(?:\.\d+)?) Focus/i); const cooldownMatch = description.match(/cannot occur more than once every (\d+(?:\.\d+)?) seconds/i); const focusPerProc = focusMatch ? Number(focusMatch[1]) : Number.NaN; const cooldownSeconds = cooldownMatch ? Number(cooldownMatch[1]) : Number.NaN; if (!Number.isFinite(focusPerProc) || focusPerProc <= 0 || !Number.isFinite(cooldownSeconds) || cooldownSeconds <= 0) return null; return Object.freeze({ description, focusPerProc, internalCooldownMs: Math.round(cooldownSeconds * 1_000), }); } /** * Plans one deterministic pet Focus proc after an eligible Hunter crit. * The caller owns pet selection, regeneration, and resource mutation; Focus * is capped to the pet's actual current maximum. */ export function wow335HunterGoForTheThroatProcPlan( rank: number, event: Wow335HunterGoForTheThroatProcEvent, ): Wow335HunterGoForTheThroatProcPlan | null { const mechanics = wow335HunterGoForTheThroatRankMechanics(rank); if (!mechanics || (event.attackType !== "melee" && event.attackType !== "ranged") || !event.critical || !event.hasLivingActivePet || !Number.isFinite(event.now) || (event.lastProcAt !== null && (!Number.isFinite(event.lastProcAt) || event.now - event.lastProcAt < mechanics.internalCooldownMs))) return null; const maximum = Math.max(0, Number.isFinite(event.focusMaximum) ? event.focusMaximum : 0); const current = Math.max(0, Math.min(maximum, Number.isFinite(event.focusCurrent) ? event.focusCurrent : 0)); const focusAfter = Math.min(maximum, current + mechanics.focusPerProc); return Object.freeze({ focusGain: focusAfter - current, focusAfter, lastProcAt: event.now, }); }