forked from phenom/healer-man
714 lines
32 KiB
TypeScript
714 lines
32 KiB
TypeScript
/**
|
|
* Small source-backed core for the selectable WoW Hunter pet families.
|
|
* Bite ranks below are the local 3.3.5a Spell.dbc rows (17253, 17255-17261,
|
|
* 27050, 52473-52474). Wolf and Turtle share these exact spell IDs.
|
|
*/
|
|
import {
|
|
normalizeWow335HunterPetFamily,
|
|
type Wow335HunterPetFamily,
|
|
} from "./wow335HunterPetFamilySelection";
|
|
import { wow335HunterPetFamilySpellForLevel } from "./wow335HunterPetFamilyData";
|
|
|
|
export type { Wow335HunterPetFamily } from "./wow335HunterPetFamilySelection";
|
|
|
|
export interface Wow335HunterPetBaseStats {
|
|
readonly level: number;
|
|
readonly maxHealth: number;
|
|
readonly strength: number;
|
|
readonly attackPower: number;
|
|
readonly minWeaponDamage: number;
|
|
readonly maxWeaponDamage: number;
|
|
readonly averageAutoAttackDamage: number;
|
|
}
|
|
|
|
export interface Wow335HunterPetOwnerCombatStats {
|
|
readonly stamina: number;
|
|
readonly rangedAttackPower: number;
|
|
}
|
|
|
|
export interface Wow335HunterPetEffectiveStats extends Wow335HunterPetBaseStats {
|
|
readonly inheritedStamina: number;
|
|
readonly inheritedRangedAttackPower: number;
|
|
readonly inheritedSpellDamage: number;
|
|
}
|
|
|
|
export interface Wow335HunterPetProfile {
|
|
readonly family: Wow335HunterPetFamily;
|
|
readonly baseStats: Wow335HunterPetBaseStats;
|
|
readonly effectiveStats: Wow335HunterPetEffectiveStats;
|
|
readonly abilityId: string;
|
|
readonly abilityName: "Bite" | "Claw";
|
|
readonly spellId: number;
|
|
readonly rank: number;
|
|
readonly requiredLevel: number;
|
|
readonly baseDamage: number;
|
|
readonly focusCost: number;
|
|
readonly globalCooldownMs: number;
|
|
readonly attackPowerCoefficient?: number;
|
|
readonly spellDamageCoefficient?: number;
|
|
readonly rake?: Wow335HunterPetRakeProfile;
|
|
readonly prowl?: Wow335HunterPetProwlProfile;
|
|
readonly furiousHowl?: Wow335HunterPetFuriousHowlProfile;
|
|
readonly shellShield?: Wow335HunterPetShellShieldProfile;
|
|
}
|
|
|
|
export interface Wow335HunterPetRakeProfile {
|
|
readonly abilityId: string;
|
|
readonly spellId: number;
|
|
readonly rank: number;
|
|
readonly requiredLevel: number;
|
|
readonly directDamage: number;
|
|
readonly periodicDamage: number;
|
|
/** Spell/effect mechanics retained to resolve core bleed armor rules. */
|
|
readonly directMechanic: number;
|
|
readonly periodicMechanic: number;
|
|
readonly periodicIgnoresArmor: boolean;
|
|
readonly focusCost: number;
|
|
readonly cooldownMs: number;
|
|
readonly categoryCooldownMs: number;
|
|
readonly globalCooldownMs: number;
|
|
readonly durationMs: number;
|
|
readonly intervalMs: number;
|
|
readonly ticks: number;
|
|
readonly attackPowerCoefficient: number;
|
|
readonly spellDamageCoefficient: number;
|
|
readonly periodicAttackPowerCoefficient: number;
|
|
readonly periodicSpellDamageCoefficient: number;
|
|
}
|
|
|
|
export interface Wow335HunterPetProwlProfile {
|
|
readonly abilityId: string;
|
|
readonly spellId: number;
|
|
readonly rank: number;
|
|
readonly requiredLevel: number;
|
|
readonly focusCost: number;
|
|
readonly cooldownMs: number;
|
|
readonly categoryCooldownMs: number;
|
|
readonly globalCooldownMs: number;
|
|
/** Source duration <= 0 represents the indefinite stance aura. */
|
|
readonly durationMs: number | null;
|
|
readonly stealthLevel: number;
|
|
readonly movementSpeedReductionPercent: number;
|
|
readonly damageBonusPercent: number;
|
|
readonly damageSchoolMask: number;
|
|
readonly breaksOnIncomingDamage: boolean;
|
|
readonly breaksOnSpellAttack: boolean;
|
|
readonly breaksOnMeleeAutoAttack: boolean;
|
|
}
|
|
|
|
export interface Wow335HunterPetShellShieldProfile {
|
|
readonly abilityId: string;
|
|
readonly spellId: number;
|
|
readonly requiredLevel: number;
|
|
readonly focusCost: number;
|
|
readonly durationMs: number;
|
|
readonly cooldownMs: number;
|
|
readonly globalCooldownMs: number;
|
|
/** Spell.dbc AuraType 87's negative damage-taken amount, as a percent. */
|
|
readonly damageTakenPercent: number;
|
|
}
|
|
|
|
export interface Wow335HunterPetFuriousHowlProfile {
|
|
readonly abilityId: string;
|
|
readonly spellId: number;
|
|
readonly rank: number;
|
|
readonly requiredLevel: number;
|
|
readonly attackPower: number;
|
|
readonly focusCost: number;
|
|
readonly durationMs: number;
|
|
readonly cooldownMs: number;
|
|
readonly globalCooldownMs: number;
|
|
}
|
|
|
|
export interface Wow335HunterPetGrowlProfile {
|
|
readonly abilityId: string;
|
|
readonly spellId: number;
|
|
readonly rank: number;
|
|
readonly requiredLevel: number;
|
|
readonly focusCost: number;
|
|
readonly cooldownMs: number;
|
|
readonly globalCooldownMs: number;
|
|
/** Flat threat from the rank's Spell.dbc SPELL_EFFECT_THREAT effect. */
|
|
readonly threatAmount: number;
|
|
}
|
|
|
|
export type Wow335HunterPetCommand = "attack" | "follow" | "stay";
|
|
export type Wow335HunterPetReactMode = "defensive" | "aggressive" | "passive";
|
|
|
|
export const WOW335_HUNTER_PET_DEFAULT_COMMAND = "follow" as const;
|
|
export const WOW335_HUNTER_PET_DEFAULT_REACT_MODE = "defensive" as const;
|
|
|
|
export interface Wow335HunterPetTargetPolicyInput {
|
|
readonly command: Wow335HunterPetCommand;
|
|
readonly reactMode: Wow335HunterPetReactMode;
|
|
/** Candidate ids here are already filtered for life, hostility, and attackability. */
|
|
readonly eligibleTargetIds: ReadonlySet<string>;
|
|
readonly commandTargetId?: string | null;
|
|
/** PetAI's transient command-attack flag can coexist with Follow/Stay command state. */
|
|
readonly isCommandAttack?: boolean;
|
|
/** Null means the pet has no living, still-valid victim. */
|
|
readonly currentTargetId?: string | null;
|
|
readonly petAttackerTargetIds?: readonly string[];
|
|
readonly ownerAttackerTargetIds?: readonly string[];
|
|
/** Supply the owner's victim only when the caller is processing an owner attack or target-selection event. */
|
|
readonly ownerTargetId?: string | null;
|
|
readonly ambientTargetIds?: readonly string[];
|
|
readonly petAtStayPosition?: boolean;
|
|
readonly isReturning?: boolean;
|
|
/** PetAI flag set by an explicit Follow action; ordinary post-combat return clears it. */
|
|
readonly isCommandFollow?: boolean;
|
|
readonly isFollowing?: boolean;
|
|
readonly inMeleeRangeTargetIds?: ReadonlySet<string>;
|
|
}
|
|
|
|
export interface Wow335HunterPetTargetPolicyDecision {
|
|
readonly targetId: string | null;
|
|
readonly source: "command" | "current" | "pet-attacker" | "owner-attacker" | "owner-target" | "ambient" | "none";
|
|
readonly movement: "chase-target" | "follow-owner" | "return-to-stay" | "hold" | "hold-stay";
|
|
readonly canAttackNow: boolean;
|
|
}
|
|
|
|
const WOLF_BITE_RANKS: readonly Omit<Wow335HunterPetProfile,
|
|
| "family"
|
|
| "abilityId"
|
|
| "abilityName"
|
|
| "focusCost"
|
|
| "globalCooldownMs"
|
|
| "furiousHowl"
|
|
| "baseStats"
|
|
| "effectiveStats"
|
|
>[] = [
|
|
{ spellId: 17_253, rank: 1, requiredLevel: 1, baseDamage: 4 },
|
|
{ spellId: 17_255, rank: 2, requiredLevel: 8, baseDamage: 8 },
|
|
{ spellId: 17_256, rank: 3, requiredLevel: 16, baseDamage: 12 },
|
|
{ spellId: 17_257, rank: 4, requiredLevel: 24, baseDamage: 16 },
|
|
{ spellId: 17_258, rank: 5, requiredLevel: 32, baseDamage: 21 },
|
|
{ spellId: 17_259, rank: 6, requiredLevel: 40, baseDamage: 26 },
|
|
{ spellId: 17_260, rank: 7, requiredLevel: 48, baseDamage: 35 },
|
|
{ spellId: 17_261, rank: 8, requiredLevel: 56, baseDamage: 43 },
|
|
{ spellId: 27_050, rank: 9, requiredLevel: 64, baseDamage: 54 },
|
|
{ spellId: 52_473, rank: 10, requiredLevel: 72, baseDamage: 91 },
|
|
{ spellId: 52_474, rank: 11, requiredLevel: 80, baseDamage: 118 },
|
|
];
|
|
|
|
// Local AzerothCore 3.3.5a db_world.pet_levelstats entry 1, levels 1-80.
|
|
// TrinityCore 3.3.5 Pet::InitStatsForLevel uses entry 1 for HUNTER_PET, while
|
|
// Guardian::UpdateAttackPowerAndDamage defines ordinary pet AP as 2 * STR - 20.
|
|
// Hunter pet base weapon bounds are set in that same initializer to level +/-
|
|
// floor(level / 4), with a 2-second base attack time.
|
|
const HUNTER_PET_HEALTH_BY_LEVEL: readonly number[] = [
|
|
42, 55, 71, 86, 102, 120, 137, 156, 176, 198, 222, 247, 273, 300, 328, 356,
|
|
386, 417, 449, 484, 521, 562, 605, 651, 699, 750, 800, 853, 905, 955, 1006, 1057,
|
|
1110, 1163, 1220, 1277, 1336, 1395, 1459, 1524, 1585, 1651, 1716, 1782, 1848, 1919, 1990, 2062,
|
|
2138, 2215, 2292, 2371, 2453, 2533, 2614, 2699, 2784, 2871, 2961, 3052, 3144, 3237, 3331, 3425,
|
|
3524, 3624, 3728, 3834, 3941, 4051, 4162, 4273, 4384, 4495, 4606, 4717, 4828, 4939, 5050, 5161,
|
|
];
|
|
|
|
const HUNTER_PET_STRENGTH_BY_LEVEL: readonly number[] = [
|
|
22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 37, 38,
|
|
40, 42, 44, 45, 47, 49, 50, 52, 53, 55, 56, 58, 60, 61, 63, 64,
|
|
66, 67, 69, 70, 72, 74, 76, 78, 81, 86, 91, 97, 102, 104, 106, 108,
|
|
110, 113, 115, 117, 119, 122, 124, 127, 129, 131, 134, 136, 139, 141, 144, 146,
|
|
149, 151, 154, 156, 159, 162, 165, 168, 171, 174, 177, 180, 183, 186, 189, 192,
|
|
];
|
|
|
|
/** Stock 3.3.5a base stats for the generic Hunter pet entry. */
|
|
export function wow335HunterPetBaseStatsForLevel(
|
|
level: number,
|
|
): Wow335HunterPetBaseStats {
|
|
const safeLevel = Math.max(1, Math.min(80, Math.trunc(level)));
|
|
const index = safeLevel - 1;
|
|
const strength = HUNTER_PET_STRENGTH_BY_LEVEL[index]!;
|
|
const attackPower = 2 * strength - 20;
|
|
const minWeaponDamage = safeLevel - Math.floor(safeLevel / 4);
|
|
const maxWeaponDamage = safeLevel + Math.floor(safeLevel / 4);
|
|
|
|
return {
|
|
level: safeLevel,
|
|
maxHealth: HUNTER_PET_HEALTH_BY_LEVEL[index]!,
|
|
strength,
|
|
attackPower,
|
|
minWeaponDamage,
|
|
maxWeaponDamage,
|
|
averageAutoAttackDamage: Math.round((minWeaponDamage + maxWeaponDamage) / 2 + attackPower / 7),
|
|
};
|
|
}
|
|
|
|
/** Apply the stock Hunter-pet stamina and ranged-AP inheritance rules. */
|
|
export function wow335HunterPetEffectiveStatsForOwner(
|
|
baseStats: Wow335HunterPetBaseStats,
|
|
ownerStats: Wow335HunterPetOwnerCombatStats,
|
|
): Wow335HunterPetEffectiveStats {
|
|
const ownerStamina = Math.max(0, Number.isFinite(ownerStats.stamina) ? ownerStats.stamina : 0);
|
|
const ownerRangedAttackPower = Math.max(
|
|
0,
|
|
Number.isFinite(ownerStats.rangedAttackPower) ? ownerStats.rangedAttackPower : 0,
|
|
);
|
|
const inheritedStamina = ownerStamina * 0.45;
|
|
const inheritedRangedAttackPower = ownerRangedAttackPower * 0.22;
|
|
const inheritedSpellDamage = ownerRangedAttackPower * WOW335_HUNTER_PET_RANGED_AP_TO_SPELL_DAMAGE;
|
|
const attackPower = baseStats.attackPower + inheritedRangedAttackPower;
|
|
|
|
return {
|
|
...baseStats,
|
|
maxHealth: baseStats.maxHealth + Math.round(inheritedStamina * 10),
|
|
attackPower,
|
|
averageAutoAttackDamage: Math.round(
|
|
(baseStats.minWeaponDamage + baseStats.maxWeaponDamage) / 2 + attackPower / 7,
|
|
),
|
|
inheritedStamina,
|
|
inheritedRangedAttackPower,
|
|
inheritedSpellDamage,
|
|
};
|
|
}
|
|
|
|
// Spell.dbc rows 24604 and 64491-64495. DBC stores AP as basePoints + 1;
|
|
// spellLevel is the pet level requirement. All ranks cost 20 Focus, last 20s,
|
|
// have a 40s recovery, and have no StartRecoveryTime (pet GCD).
|
|
const WOLF_FURIOUS_HOWL_RANKS: readonly Wow335HunterPetFuriousHowlProfile[] = [
|
|
{ abilityId: "wow335-hunter-pet-furious-howl-rank-1", spellId: 24_604, rank: 1, requiredLevel: 1, attackPower: 18, focusCost: 20, durationMs: 20_000, cooldownMs: 40_000, globalCooldownMs: 0 },
|
|
{ abilityId: "wow335-hunter-pet-furious-howl-rank-2", spellId: 64_491, rank: 2, requiredLevel: 16, attackPower: 43, focusCost: 20, durationMs: 20_000, cooldownMs: 40_000, globalCooldownMs: 0 },
|
|
{ abilityId: "wow335-hunter-pet-furious-howl-rank-3", spellId: 64_492, rank: 3, requiredLevel: 32, attackPower: 70, focusCost: 20, durationMs: 20_000, cooldownMs: 40_000, globalCooldownMs: 0 },
|
|
{ abilityId: "wow335-hunter-pet-furious-howl-rank-4", spellId: 64_493, rank: 4, requiredLevel: 48, attackPower: 120, focusCost: 20, durationMs: 20_000, cooldownMs: 40_000, globalCooldownMs: 0 },
|
|
{ abilityId: "wow335-hunter-pet-furious-howl-rank-5", spellId: 64_494, rank: 5, requiredLevel: 64, attackPower: 204, focusCost: 20, durationMs: 20_000, cooldownMs: 40_000, globalCooldownMs: 0 },
|
|
{ abilityId: "wow335-hunter-pet-furious-howl-rank-6", spellId: 64_495, rank: 6, requiredLevel: 80, attackPower: 320, focusCost: 20, durationMs: 20_000, cooldownMs: 40_000, globalCooldownMs: 0 },
|
|
];
|
|
|
|
// Local 3.3.5a Spell.dbc Growl ranks (2649, 14916-14921, 27047, 61676).
|
|
// Each rank uses SPELL_EFFECT_THREAT (63), costs 15 Focus, has a 5s category
|
|
// cooldown, and starts no pet GCD. The DBC payload is flat threat; it does not
|
|
// define a timed forced-target/taunt effect.
|
|
const WOLF_GROWL_RANKS: readonly Wow335HunterPetGrowlProfile[] = [
|
|
{ abilityId: "wow335-hunter-pet-growl-rank-1", spellId: 2_649, rank: 1, requiredLevel: 1, focusCost: 15, cooldownMs: 5_000, globalCooldownMs: 0, threatAmount: 50 },
|
|
{ abilityId: "wow335-hunter-pet-growl-rank-2", spellId: 14_916, rank: 2, requiredLevel: 10, focusCost: 15, cooldownMs: 5_000, globalCooldownMs: 0, threatAmount: 65 },
|
|
{ abilityId: "wow335-hunter-pet-growl-rank-3", spellId: 14_917, rank: 3, requiredLevel: 20, focusCost: 15, cooldownMs: 5_000, globalCooldownMs: 0, threatAmount: 110 },
|
|
{ abilityId: "wow335-hunter-pet-growl-rank-4", spellId: 14_918, rank: 4, requiredLevel: 30, focusCost: 15, cooldownMs: 5_000, globalCooldownMs: 0, threatAmount: 170 },
|
|
{ abilityId: "wow335-hunter-pet-growl-rank-5", spellId: 14_919, rank: 5, requiredLevel: 40, focusCost: 15, cooldownMs: 5_000, globalCooldownMs: 0, threatAmount: 240 },
|
|
{ abilityId: "wow335-hunter-pet-growl-rank-6", spellId: 14_920, rank: 6, requiredLevel: 50, focusCost: 15, cooldownMs: 5_000, globalCooldownMs: 0, threatAmount: 320 },
|
|
{ abilityId: "wow335-hunter-pet-growl-rank-7", spellId: 14_921, rank: 7, requiredLevel: 60, focusCost: 15, cooldownMs: 5_000, globalCooldownMs: 0, threatAmount: 415 },
|
|
{ abilityId: "wow335-hunter-pet-growl-rank-8", spellId: 27_047, rank: 8, requiredLevel: 70, focusCost: 15, cooldownMs: 5_000, globalCooldownMs: 0, threatAmount: 664 },
|
|
{ abilityId: "wow335-hunter-pet-growl-rank-9", spellId: 61_676, rank: 9, requiredLevel: 80, focusCost: 15, cooldownMs: 5_000, globalCooldownMs: 0, threatAmount: 1_234 },
|
|
];
|
|
|
|
// Turtle family skill line 251's local SkillLineAbility.dbc / Spell.dbc row:
|
|
// Shell Shield (26064), spell level 1, 0 Focus, 60s cooldown, 12s duration,
|
|
// AuraType 87 with basePoints -50. Selection is level-gated to that source row.
|
|
const TURTLE_SHELL_SHIELD: Wow335HunterPetShellShieldProfile = {
|
|
abilityId: "wow335-hunter-pet-shell-shield",
|
|
spellId: 26_064,
|
|
requiredLevel: 1,
|
|
focusCost: 0,
|
|
durationMs: 12_000,
|
|
cooldownMs: 60_000,
|
|
globalCooldownMs: 0,
|
|
damageTakenPercent: 50,
|
|
};
|
|
|
|
const CAT_FAMILY_ID = 2;
|
|
const WOW335_MECHANIC_BLEED = 15;
|
|
const WOW335_AURA_INTERRUPT_FLAG_HIT_BY_SPELL = 0x0000_0001;
|
|
const WOW335_AURA_INTERRUPT_FLAG_TAKE_DAMAGE = 0x0000_0002;
|
|
const WOW335_AURA_INTERRUPT_FLAG_SPELL_ATTACK = 0x0000_2000;
|
|
const WOW335_PROC_FLAG_DONE_MELEE_AUTO_ATTACK = 0x0000_0004;
|
|
const WOW335_SPELL_SCHOOL_MASKS = [
|
|
{ mask: 0x01, school: "physical" as const },
|
|
{ mask: 0x02, school: "holy" as const },
|
|
{ mask: 0x04, school: "fire" as const },
|
|
{ mask: 0x08, school: "nature" as const },
|
|
{ mask: 0x10, school: "frost" as const },
|
|
{ mask: 0x20, school: "shadow" as const },
|
|
{ mask: 0x40, school: "arcane" as const },
|
|
] as const;
|
|
|
|
// Local AzerothCore spell_bonus_data rows for all Cat Claw ranks (direct .119658 / AP .07)
|
|
// and Rake ranks (direct and periodic spell .0299 / AP .0175).
|
|
const WOW335_HUNTER_PET_CLAW_ATTACK_POWER_COEFFICIENT = 0.07;
|
|
const WOW335_HUNTER_PET_CLAW_SPELL_DAMAGE_COEFFICIENT = 0.119658;
|
|
const WOW335_HUNTER_PET_RAKE_ATTACK_POWER_COEFFICIENT = 0.0175;
|
|
const WOW335_HUNTER_PET_RAKE_SPELL_DAMAGE_COEFFICIENT = 0.0299;
|
|
|
|
function sourceRankNumber(rankText: string): number {
|
|
return Number(rankText.match(/^Rank\s+(\d+)$/i)?.[1] ?? 1);
|
|
}
|
|
|
|
/** Simulator attacks use the midpoint of the Spell.dbc base-points/die-sides range. */
|
|
function sourceDamageMidpoint(effect: { readonly basePoints: number; readonly dieSides: number }): number {
|
|
const minimum = effect.basePoints;
|
|
const maximum = minimum + Math.max(0, Math.abs(effect.dieSides) - 1);
|
|
return (minimum + maximum) / 2;
|
|
}
|
|
|
|
function catSpellEffect(
|
|
spellName: "Claw" | "Rake" | "Prowl",
|
|
level: number,
|
|
): NonNullable<ReturnType<typeof wow335HunterPetFamilySpellForLevel>> {
|
|
const spell = wow335HunterPetFamilySpellForLevel(CAT_FAMILY_ID, spellName, level);
|
|
if (!spell) throw new Error(`Cat pet skill line is missing a learned ${spellName} rank.`);
|
|
return spell;
|
|
}
|
|
|
|
function wow335HunterCatRakeProfileForLevel(level: number): Wow335HunterPetRakeProfile {
|
|
const source = catSpellEffect("Rake", level);
|
|
const direct = source.effects.find((effect) => effect.index === 0 && effect.effectType === 2);
|
|
const periodic = source.effects.find((effect) => effect.index === 1 && effect.effectType === 6 && effect.auraType === 3);
|
|
const intervalMs = periodic?.periodMs ?? 0;
|
|
const ticks = intervalMs > 0 && source.durationMs > 0 && source.durationMs % intervalMs === 0
|
|
? source.durationMs / intervalMs
|
|
: 0;
|
|
if (!direct || !periodic || ticks <= 0) {
|
|
throw new Error(`Cat Rake rank ${source.spellId} has unsupported local damage effects.`);
|
|
}
|
|
return {
|
|
abilityId: `wow335-hunter-pet-rake-rank-${sourceRankNumber(source.rankText)}`,
|
|
spellId: source.spellId,
|
|
rank: sourceRankNumber(source.rankText),
|
|
requiredLevel: source.requiredLevel,
|
|
directDamage: sourceDamageMidpoint(direct),
|
|
periodicDamage: sourceDamageMidpoint(periodic),
|
|
directMechanic: source.mechanic || direct.mechanic,
|
|
periodicMechanic: source.mechanic || periodic.mechanic,
|
|
periodicIgnoresArmor: source.mechanic === WOW335_MECHANIC_BLEED
|
|
|| periodic.mechanic === WOW335_MECHANIC_BLEED,
|
|
focusCost: source.powerCost,
|
|
cooldownMs: source.cooldownMs,
|
|
categoryCooldownMs: source.categoryCooldownMs,
|
|
globalCooldownMs: source.globalCooldownMs,
|
|
durationMs: source.durationMs,
|
|
intervalMs,
|
|
ticks,
|
|
attackPowerCoefficient: WOW335_HUNTER_PET_RAKE_ATTACK_POWER_COEFFICIENT,
|
|
spellDamageCoefficient: WOW335_HUNTER_PET_RAKE_SPELL_DAMAGE_COEFFICIENT,
|
|
periodicAttackPowerCoefficient: WOW335_HUNTER_PET_RAKE_ATTACK_POWER_COEFFICIENT,
|
|
periodicSpellDamageCoefficient: WOW335_HUNTER_PET_RAKE_SPELL_DAMAGE_COEFFICIENT,
|
|
};
|
|
}
|
|
|
|
function wow335HunterCatProwlProfileForLevel(level: number): Wow335HunterPetProwlProfile | null {
|
|
const source = wow335HunterPetFamilySpellForLevel(CAT_FAMILY_ID, "Prowl", level);
|
|
if (!source) return null;
|
|
const stealth = source.effects.find((effect) => effect.auraType === 16);
|
|
const slow = source.effects.find((effect) => effect.auraType === 33);
|
|
const damage = source.effects.find((effect) => effect.auraType === 79);
|
|
if (!stealth || !slow || !damage) {
|
|
throw new Error(`Cat Prowl rank ${source.spellId} has unsupported local aura effects.`);
|
|
}
|
|
return {
|
|
abilityId: WOW335_HUNTER_PET_PROWL_TAG,
|
|
spellId: source.spellId,
|
|
rank: sourceRankNumber(source.rankText),
|
|
requiredLevel: source.requiredLevel,
|
|
focusCost: source.powerCost,
|
|
cooldownMs: source.cooldownMs,
|
|
categoryCooldownMs: source.categoryCooldownMs,
|
|
globalCooldownMs: source.globalCooldownMs,
|
|
durationMs: source.durationMs > 0 ? source.durationMs : null,
|
|
stealthLevel: stealth.basePoints,
|
|
movementSpeedReductionPercent: Math.abs(slow.basePoints),
|
|
damageBonusPercent: damage.basePoints,
|
|
damageSchoolMask: damage.miscValue,
|
|
breaksOnIncomingDamage: Boolean(source.auraInterruptFlags & (
|
|
WOW335_AURA_INTERRUPT_FLAG_HIT_BY_SPELL | WOW335_AURA_INTERRUPT_FLAG_TAKE_DAMAGE
|
|
)),
|
|
breaksOnSpellAttack: Boolean(source.auraInterruptFlags & WOW335_AURA_INTERRUPT_FLAG_SPELL_ATTACK),
|
|
breaksOnMeleeAutoAttack: source.procCharges > 0
|
|
&& Boolean(source.procFlags & WOW335_PROC_FLAG_DONE_MELEE_AUTO_ATTACK),
|
|
};
|
|
}
|
|
|
|
export const WOW335_HUNTER_PET_KILL_COMMAND_TAG = "wow335-hunter-kill-command";
|
|
export const WOW335_HUNTER_PET_FURIOUS_HOWL_TAG = "wow335-hunter-pet-furious-howl";
|
|
export const WOW335_HUNTER_PET_SHELL_SHIELD_TAG = "wow335-hunter-pet-shell-shield";
|
|
export const WOW335_HUNTER_PET_PROWL_TAG = "wow335-hunter-pet-prowl";
|
|
export const WOW335_HUNTER_PET_RAKE_TAG = "wow335-hunter-pet-rake";
|
|
export const WOW335_HUNTER_PET_AUTO_ATTACK_TIME_MS = 2_000;
|
|
export const WOW335_HUNTER_PET_KILL_COMMAND_DURATION_MS = 30_000;
|
|
export const WOW335_HUNTER_PET_KILL_COMMAND_COOLDOWN_MS = 60_000;
|
|
export const WOW335_HUNTER_PET_KILL_COMMAND_INITIAL_BONUS_PERCENT = 60;
|
|
export const WOW335_HUNTER_PET_KILL_COMMAND_DECAY_PERCENT = 20;
|
|
export const WOW335_HUNTER_PET_KILL_COMMAND_SPECIAL_CHARGES = 3;
|
|
// Local AzerothCore spell_bonus_data rows for Bite ranks 17253-17261, 27050,
|
|
// 52473, and 52474 list direct spell damage 0.119658 and AP 0.07 coefficients.
|
|
// The 3.3.5a PaperDollFrame.lua UI source lists pet spell damage as 12.87% RAP.
|
|
export const WOW335_HUNTER_PET_RANGED_AP_TO_SPELL_DAMAGE = 0.1287;
|
|
export const WOW335_HUNTER_PET_BITE_SPELL_DAMAGE_COEFFICIENT = 0.119658;
|
|
export const WOW335_HUNTER_PET_BITE_ATTACK_POWER_COEFFICIENT = 0.07;
|
|
|
|
/** Bite base damage plus the source-listed pet-AP and pet-spell-damage bonuses. */
|
|
export function wow335HunterPetBiteDamage(
|
|
profile: Wow335HunterPetProfile,
|
|
additionalAttackPower = 0,
|
|
): number {
|
|
const extraAttackPower = Number.isFinite(additionalAttackPower) ? additionalAttackPower : 0;
|
|
const attackPower = Math.max(0, profile.effectiveStats.attackPower + extraAttackPower);
|
|
return profile.baseDamage
|
|
+ attackPower * WOW335_HUNTER_PET_BITE_ATTACK_POWER_COEFFICIENT
|
|
+ profile.effectiveStats.inheritedSpellDamage * WOW335_HUNTER_PET_BITE_SPELL_DAMAGE_COEFFICIENT;
|
|
}
|
|
|
|
/** Claw's sourced per-rank bonus coefficients over the DBC base damage. */
|
|
export function wow335HunterPetClawDamage(
|
|
profile: Wow335HunterPetProfile,
|
|
additionalAttackPower = 0,
|
|
): number {
|
|
const attackPower = Math.max(0, profile.effectiveStats.attackPower
|
|
+ (Number.isFinite(additionalAttackPower) ? additionalAttackPower : 0));
|
|
return profile.baseDamage
|
|
+ attackPower * (profile.attackPowerCoefficient ?? 0)
|
|
+ profile.effectiveStats.inheritedSpellDamage * (profile.spellDamageCoefficient ?? 0);
|
|
}
|
|
|
|
/** Rake's sourced direct or periodic spell/AP coefficients over its DBC range. */
|
|
export function wow335HunterPetRakeDamage(
|
|
profile: Wow335HunterPetProfile,
|
|
rake: Wow335HunterPetRakeProfile,
|
|
periodic = false,
|
|
additionalAttackPower = 0,
|
|
): number {
|
|
const attackPower = Math.max(0, profile.effectiveStats.attackPower
|
|
+ (Number.isFinite(additionalAttackPower) ? additionalAttackPower : 0));
|
|
return (periodic ? rake.periodicDamage : rake.directDamage)
|
|
+ attackPower * (periodic ? rake.periodicAttackPowerCoefficient : rake.attackPowerCoefficient)
|
|
+ profile.effectiveStats.inheritedSpellDamage
|
|
* (periodic ? rake.periodicSpellDamageCoefficient : rake.spellDamageCoefficient);
|
|
}
|
|
|
|
/** Furious Howl rank learned by this Wolf, selected from Spell.dbc spellLevel. */
|
|
export function wow335HunterPetFuriousHowlProfileForLevel(
|
|
level: number,
|
|
): Wow335HunterPetFuriousHowlProfile {
|
|
const safeLevel = Math.max(1, Math.min(80, Math.trunc(level)));
|
|
return [...WOLF_FURIOUS_HOWL_RANKS].reverse().find((candidate) => candidate.requiredLevel <= safeLevel)
|
|
?? WOLF_FURIOUS_HOWL_RANKS[0]!;
|
|
}
|
|
|
|
/** Growl rank learned by this Wolf, selected from local Spell.dbc spellLevel. */
|
|
export function wow335HunterPetGrowlProfileForLevel(level: number): Wow335HunterPetGrowlProfile {
|
|
const safeLevel = Number.isFinite(level)
|
|
? Math.max(1, Math.min(80, Math.trunc(level)))
|
|
: 1;
|
|
return [...WOLF_GROWL_RANKS].reverse().find((candidate) => candidate.requiredLevel <= safeLevel)
|
|
?? WOLF_GROWL_RANKS[0]!;
|
|
}
|
|
|
|
/**
|
|
* Resolve stock 3.3.5 pet command/react target priority from already-eligible ids.
|
|
* Owner-target candidates are supplied by the caller only for an OwnerAttacked
|
|
* event or a source path that actually runs next-target selection.
|
|
*/
|
|
export function wow335HunterPetTargetPolicy(
|
|
input: Wow335HunterPetTargetPolicyInput,
|
|
): Wow335HunterPetTargetPolicyDecision {
|
|
const isEligible = (targetId: string | null | undefined): targetId is string => Boolean(
|
|
targetId && input.eligibleTargetIds.has(targetId),
|
|
);
|
|
let targetId: string | null = null;
|
|
let source: Wow335HunterPetTargetPolicyDecision["source"] = "none";
|
|
const hasExplicitAttackOrder = input.command === "attack" || Boolean(input.isCommandAttack);
|
|
// The Follow action calls AttackStop and marks the pet as returning. A pet
|
|
// that has reached its follow position can engage again; an active victim
|
|
// acquired after that point is still retained while the command state says
|
|
// Follow. Stay does not clear the victim when its command is issued.
|
|
const returningToOwner = input.command === "follow"
|
|
&& Boolean(input.isReturning)
|
|
&& Boolean(input.isCommandFollow);
|
|
const isSelectable = (candidateId: string): boolean => input.eligibleTargetIds.has(candidateId)
|
|
&& (input.command !== "stay"
|
|
|| hasExplicitAttackOrder
|
|
|| Boolean(input.inMeleeRangeTargetIds?.has(candidateId)));
|
|
|
|
if (hasExplicitAttackOrder) {
|
|
if (isEligible(input.commandTargetId)) {
|
|
targetId = input.commandTargetId;
|
|
source = "command";
|
|
}
|
|
}
|
|
if (!targetId && !returningToOwner && isEligible(input.currentTargetId)
|
|
&& (input.reactMode !== "passive" || hasExplicitAttackOrder)) {
|
|
// PetAI keeps a living victim until it dies/becomes invalid. An explicit
|
|
// Follow action already clears the victim; issuing Stay does not. Switching
|
|
// to Passive also calls AttackStop, unless the later Attack order is active.
|
|
targetId = input.currentTargetId;
|
|
source = hasExplicitAttackOrder && isEligible(input.commandTargetId)
|
|
? "command"
|
|
: "current";
|
|
} else if (!targetId && !returningToOwner && input.reactMode !== "passive") {
|
|
// Passive pets do not auto-select. A fresh explicit Attack is handled above.
|
|
const firstEligible = (ids: readonly string[] | undefined): string | null => (
|
|
ids?.find(isSelectable) ?? null
|
|
);
|
|
targetId = firstEligible(input.petAttackerTargetIds);
|
|
if (targetId) source = "pet-attacker";
|
|
if (!targetId) {
|
|
targetId = firstEligible(input.ownerAttackerTargetIds);
|
|
if (targetId) source = "owner-attacker";
|
|
}
|
|
if (!targetId && input.ownerTargetId && isSelectable(input.ownerTargetId)) {
|
|
targetId = input.ownerTargetId;
|
|
source = "owner-target";
|
|
}
|
|
const canAggressivelyAutoSelect = !input.isReturning
|
|
|| input.isFollowing
|
|
|| input.petAtStayPosition;
|
|
if (!targetId && input.reactMode === "aggressive" && canAggressivelyAutoSelect) {
|
|
targetId = firstEligible(input.ambientTargetIds);
|
|
if (targetId) source = "ambient";
|
|
}
|
|
}
|
|
|
|
const inMeleeRange = Boolean(
|
|
targetId && input.inMeleeRangeTargetIds?.has(targetId),
|
|
);
|
|
const canAttackNow = Boolean(
|
|
targetId
|
|
&& inMeleeRange
|
|
&& (input.command !== "stay" || input.petAtStayPosition || hasExplicitAttackOrder),
|
|
);
|
|
|
|
let movement: Wow335HunterPetTargetPolicyDecision["movement"];
|
|
if (targetId) {
|
|
movement = input.command === "stay" && !hasExplicitAttackOrder ? "hold-stay" : "chase-target";
|
|
} else if (input.command === "follow") {
|
|
movement = "follow-owner";
|
|
} else if (input.command === "stay") {
|
|
movement = input.petAtStayPosition ? "hold" : "return-to-stay";
|
|
} else {
|
|
movement = "hold";
|
|
}
|
|
|
|
return { targetId, source, movement, canAttackNow };
|
|
}
|
|
|
|
export function wow335HunterPetFuriousHowlAura(profile: Wow335HunterPetFuriousHowlProfile) {
|
|
return {
|
|
id: WOW335_HUNTER_PET_FURIOUS_HOWL_TAG,
|
|
name: "Furious Howl",
|
|
description: `Increases melee and ranged attack power by ${profile.attackPower} for the Wolf and its master for 20 seconds.`,
|
|
disposition: "buff" as const,
|
|
durationMs: profile.durationMs,
|
|
maxStacks: 1,
|
|
stackBehavior: "refresh" as const,
|
|
modifiers: [
|
|
{ kind: "stat" as const, stat: "attack-power", operation: "flat" as const, value: profile.attackPower },
|
|
{ kind: "stat" as const, stat: "ranged-attack-power", operation: "flat" as const, value: profile.attackPower },
|
|
],
|
|
utilityTags: [WOW335_HUNTER_PET_FURIOUS_HOWL_TAG],
|
|
};
|
|
}
|
|
|
|
/** Turtle's DBC AuraType 87 applies a 50% all-damage-taken reduction. */
|
|
export function wow335HunterPetShellShieldAura(profile: Wow335HunterPetShellShieldProfile) {
|
|
return {
|
|
id: WOW335_HUNTER_PET_SHELL_SHIELD_TAG,
|
|
name: "Shell Shield",
|
|
description: `Reduces the Turtle's damage taken by ${profile.damageTakenPercent}% for 12 seconds.`,
|
|
disposition: "buff" as const,
|
|
durationMs: profile.durationMs,
|
|
maxStacks: 1,
|
|
stackBehavior: "refresh" as const,
|
|
modifiers: [{
|
|
kind: "damage" as const,
|
|
direction: "taken" as const,
|
|
operation: "percent" as const,
|
|
value: -profile.damageTakenPercent / 100,
|
|
}],
|
|
utilityTags: [WOW335_HUNTER_PET_SHELL_SHIELD_TAG],
|
|
};
|
|
}
|
|
|
|
/** Cat Prowl's local AuraType 16/33/79 effects belong to the pet itself. */
|
|
export function wow335HunterPetProwlAura(profile: Wow335HunterPetProwlProfile) {
|
|
const damageModifiers = WOW335_SPELL_SCHOOL_MASKS.flatMap(({ mask, school }) => (
|
|
profile.damageSchoolMask & mask
|
|
? [{ kind: "damage" as const, direction: "dealt" as const, school, operation: "percent" as const,
|
|
value: profile.damageBonusPercent / 100 }]
|
|
: []
|
|
));
|
|
return {
|
|
id: WOW335_HUNTER_PET_PROWL_TAG,
|
|
name: "Prowl",
|
|
description: `Stealthed; movement speed reduced by ${profile.movementSpeedReductionPercent}%; next attack gains ${profile.damageBonusPercent}% damage.`,
|
|
disposition: "buff" as const,
|
|
durationMs: profile.durationMs,
|
|
maxStacks: 1,
|
|
stackBehavior: "refresh" as const,
|
|
modifiers: damageModifiers,
|
|
utilityTags: ["stealth", WOW335_HUNTER_PET_PROWL_TAG],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A flat pet AP increase adds AP / 14 * weapon speed seconds to each melee
|
|
* swing. Hunter pets use the 2-second BASE_ATTACK_TIME in the local 3.3.5
|
|
* Pet::InitStatsForLevel source.
|
|
*/
|
|
export function wow335HunterPetFuriousHowlAutoAttackDamageBonus(
|
|
profile: Wow335HunterPetFuriousHowlProfile,
|
|
): number {
|
|
return profile.attackPower * (WOW335_HUNTER_PET_AUTO_ATTACK_TIME_MS / 1_000) / 14;
|
|
}
|
|
|
|
/** Select the highest Bite rank present in the local 3.3.5a DBC at this pet level. */
|
|
export function wow335HunterPetProfileForLevel(
|
|
level: number,
|
|
family: Wow335HunterPetFamily = "wolf",
|
|
ownerStats: Wow335HunterPetOwnerCombatStats = { stamina: 0, rangedAttackPower: 0 },
|
|
): Wow335HunterPetProfile {
|
|
const safeLevel = Math.max(1, Math.min(80, Math.trunc(level)));
|
|
const selectedFamily = normalizeWow335HunterPetFamily(family);
|
|
const baseStats = wow335HunterPetBaseStatsForLevel(safeLevel);
|
|
const effectiveStats = wow335HunterPetEffectiveStatsForOwner(baseStats, ownerStats);
|
|
|
|
if (selectedFamily === "cat") {
|
|
const clawSource = catSpellEffect("Claw", safeLevel);
|
|
const clawDamage = clawSource.effects.find((effect) => effect.effectType === 2);
|
|
if (!clawDamage) throw new Error(`Cat Claw rank ${clawSource.spellId} is missing its direct damage effect.`);
|
|
const rank = sourceRankNumber(clawSource.rankText);
|
|
return {
|
|
family: selectedFamily,
|
|
baseStats,
|
|
effectiveStats,
|
|
abilityId: `wow335-hunter-pet-claw-rank-${rank}`,
|
|
abilityName: "Claw",
|
|
spellId: clawSource.spellId,
|
|
rank,
|
|
requiredLevel: clawSource.requiredLevel,
|
|
baseDamage: sourceDamageMidpoint(clawDamage),
|
|
focusCost: clawSource.powerCost,
|
|
globalCooldownMs: clawSource.globalCooldownMs,
|
|
attackPowerCoefficient: WOW335_HUNTER_PET_CLAW_ATTACK_POWER_COEFFICIENT,
|
|
spellDamageCoefficient: WOW335_HUNTER_PET_CLAW_SPELL_DAMAGE_COEFFICIENT,
|
|
rake: wow335HunterCatRakeProfileForLevel(safeLevel),
|
|
prowl: wow335HunterCatProwlProfileForLevel(safeLevel) ?? undefined,
|
|
};
|
|
}
|
|
|
|
const rank = [...WOLF_BITE_RANKS].reverse().find((candidate) => candidate.requiredLevel <= safeLevel)
|
|
?? WOLF_BITE_RANKS[0]!;
|
|
const sourceFamilyId = selectedFamily === "turtle" ? 21 : 1;
|
|
const biteSource = wow335HunterPetFamilySpellForLevel(sourceFamilyId, "Bite", safeLevel);
|
|
return {
|
|
...rank,
|
|
family: selectedFamily,
|
|
baseStats,
|
|
effectiveStats: wow335HunterPetEffectiveStatsForOwner(baseStats, ownerStats),
|
|
abilityName: "Bite",
|
|
abilityId: `wow335-hunter-pet-bite-rank-${rank.rank}`,
|
|
focusCost: biteSource?.powerCost ?? 25,
|
|
globalCooldownMs: biteSource?.globalCooldownMs ?? 1_500,
|
|
...(selectedFamily === "wolf"
|
|
? { furiousHowl: wow335HunterPetFuriousHowlProfileForLevel(safeLevel) }
|
|
: {}),
|
|
...(selectedFamily === "turtle" && safeLevel >= TURTLE_SHELL_SHIELD.requiredLevel
|
|
? { shellShield: TURTLE_SHELL_SHIELD }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
/** Kill Command starts at 60%; each landed pet special attack removes 20 points. */
|
|
export function wow335HunterPetKillCommandBonusPercent(remainingSpecialCharges: number): number {
|
|
return Math.max(0, Math.min(
|
|
WOW335_HUNTER_PET_KILL_COMMAND_INITIAL_BONUS_PERCENT,
|
|
Math.trunc(remainingSpecialCharges) * WOW335_HUNTER_PET_KILL_COMMAND_DECAY_PERCENT,
|
|
));
|
|
}
|