Files
healer-man/src/game/wow335ShamanNaturesGuardianRuntime.ts
T

143 lines
5.9 KiB
TypeScript

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)));
}