Files
healer-man/src/game/wow335HunterGoForTheThroatRuntime.ts
T
2026-10-02 10:38:12 -04:00

119 lines
4.6 KiB
TypeScript

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<Record<string, "melee" | "ranged">> = 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,
});
}