143 lines
5.9 KiB
TypeScript
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)));
|
|
}
|