import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; import { applyAura, type ActiveAura, type AuraDefinition } from "./combatAuras"; import type { WotlkAttackKind } from "./wotlkStats"; export const WOW335_SHAMAN_NATURES_GUARDIAN_TALENT_ID = "talent-1699"; export const WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG = "wow335-shaman-natures-guardian-next-nature-crit"; export const WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG = "wow335-shaman-natures-guardian-cooldown"; const NATURES_GUARDIAN_SOURCE = WOW335_TALENT_NODES.find((node) => ( node.id === WOW335_SHAMAN_NATURES_GUARDIAN_TALENT_ID )); export interface Wow335ShamanNaturesGuardianProfile { readonly rank: number; readonly procChancePercent: number; readonly healthThresholdPercent: number; readonly cooldownMs: number; } export interface Wow335ShamanNaturesGuardianIncomingEvent { readonly attackKind: WotlkAttackKind; readonly directDamage: boolean; readonly landed: boolean; readonly healthDamage: number; readonly healthBefore: number; readonly healthAfter: number; readonly maxHealth: number; /** Stable uniform roll in the inclusive range [0, 1). */ readonly roll: number; } /** Reads the capstone proc chance and threshold from generated rank source. */ export function wow335ShamanNaturesGuardianProfileForRank( rank: number, ): Wow335ShamanNaturesGuardianProfile | null { const maxRank = NATURES_GUARDIAN_SOURCE?.rankEffects.length ?? 0; const safeRank = Number.isFinite(rank) ? Math.max(0, Math.min(maxRank, Math.trunc(rank))) : 0; if (safeRank <= 0) return null; const rankEffect = NATURES_GUARDIAN_SOURCE?.rankEffects[safeRank - 1]; const description = NATURES_GUARDIAN_SOURCE?.rankDescriptions[safeRank - 1]; const thresholdMatch = description?.match(/brings you below (\d+(?:\.\d+)?)% health/i); const cooldownMatch = description?.match(/effect has a (\d+(?:\.\d+)?) second cooldown/i); const procChancePercent = rankEffect?.procChance; const healthThresholdPercent = thresholdMatch ? Number(thresholdMatch[1]) : Number.NaN; const cooldownMs = cooldownMatch ? Number(cooldownMatch[1]) * 1_000 : Number.NaN; if (!Number.isFinite(procChancePercent) || procChancePercent! < 0 || procChancePercent! > 100 || !Number.isFinite(healthThresholdPercent) || healthThresholdPercent <= 0 || healthThresholdPercent >= 100 || !Number.isFinite(cooldownMs) || cooldownMs <= 0 || !/your next Nature spell is guaranteed to critically strike/i.test(description ?? "")) return null; return { rank: safeRank, procChancePercent: procChancePercent!, healthThresholdPercent, cooldownMs }; } /** The tooltip says the damaging attack brings the Shaman below the threshold. */ export function wow335ShamanNaturesGuardianShouldProc( rank: number, event: Wow335ShamanNaturesGuardianIncomingEvent, ): boolean { const profile = wow335ShamanNaturesGuardianProfileForRank(rank); if (!profile || !event.directDamage || !event.landed || event.healthDamage <= 0 || !Number.isFinite(event.maxHealth) || event.maxHealth <= 0 || !Number.isFinite(event.healthBefore) || !Number.isFinite(event.healthAfter) || !Number.isFinite(event.roll) || event.roll < 0 || event.roll >= 1) return false; const threshold = profile.healthThresholdPercent / 100; const healthBefore = event.healthBefore / event.maxHealth; const healthAfter = event.healthAfter / event.maxHealth; return healthBefore >= threshold && healthAfter < threshold && event.roll < profile.procChancePercent / 100; } function cooldownActive(auras: readonly ActiveAura[], actorId: string, now: number): boolean { return auras.some((aura) => aura.sourceId === actorId && aura.targetId === actorId && aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); } export function wow335ShamanNaturesGuardianApplyProc( auras: readonly ActiveAura[], actorId: string, rank: number, event: Wow335ShamanNaturesGuardianIncomingEvent, now: number, ): readonly ActiveAura[] { const profile = wow335ShamanNaturesGuardianProfileForRank(rank); if (!profile || cooldownActive(auras, actorId, now) || !wow335ShamanNaturesGuardianShouldProc(rank, event)) { return auras; } const nextNatureCrit: AuraDefinition = { id: WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG, name: "Nature's Guardian", description: "Your next Nature spell is guaranteed to critically strike.", disposition: "buff", // The generated tooltip gives no expiration for this charge. durationMs: null, maxStacks: 1, stackBehavior: "replace", utilityTags: [WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG], }; const cooldown: AuraDefinition = { id: WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG, name: "Nature's Guardian cooldown", disposition: "buff", durationMs: profile.cooldownMs, maxStacks: 1, stackBehavior: "refresh", utilityTags: [WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG], }; const withCharge = applyAura(auras, { definition: nextNatureCrit, sourceId: actorId, targetId: actorId, now, }).auras; return applyAura(withCharge, { definition: cooldown, sourceId: actorId, targetId: actorId, now, }).auras; } export function wow335ShamanNaturesGuardianNextCritIsReady( auras: readonly ActiveAura[], actorId: string, now: number, ): boolean { return auras.some((aura) => aura.sourceId === actorId && aura.targetId === actorId && aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); } export function wow335ShamanNaturesGuardianConsumeNextCrit( auras: readonly ActiveAura[], actorId: string, ): readonly ActiveAura[] { return auras.filter((aura) => !(aura.sourceId === actorId && aura.targetId === actorId && aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG))); }