import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; import type { DeathKnightRuneState } from "./combatActors"; /** * Reaping's talent chance and 30-second hidden Periodic Dummy interval come * from the local generated 3.3.5a talent rows, owned by * `scripts/generate-wow335-talents.mjs`. Rune selection/expiry and hit-versus- * miss spend semantics follow the inspected AzerothCore-derived reference * (the linked WotLKDB row reports interval 30s and duration n/a): * https://wotlkdb.com/index.php?spell=56835 * https://github.com/mod-playerbots/azerothcore-wotlk/blob/e822d718322289dd036fb2daf8867ba41283bf9a/src/server/scripts/Spells/spell_dk.cpp#L2640-L2705 * https://github.com/mod-playerbots/azerothcore-wotlk/blob/e822d718322289dd036fb2daf8867ba41283bf9a/src/server/game/Spells/Spell.cpp#L5413-L5474 * This server handler is a fork reference, not a claim about pristine upstream * TrinityCore behavior. */ export const WOW335_DEATH_KNIGHT_REAPING_TALENT_ID = "talent-2001"; export const WOW335_DEATH_KNIGHT_REAPING_PROC_AURA_TYPE = 226; export const WOW335_DEATH_KNIGHT_REAPING_TRIGGER_SPELL_IDS = Object.freeze([ 45902, // Blood Strike 50842, // Pestilence ]); const reapingTalent = WOW335_TALENT_NODES.find( (talent) => talent.id === WOW335_DEATH_KNIGHT_REAPING_TALENT_ID, ); /** Reads the selected proc chance directly from the generated local 3.3.5a rank row. */ export function wow335DeathKnightReapingProcChancePercent(rank: number): number { const selectedRank = Math.max(0, Math.trunc(Number.isFinite(rank) ? rank : 0)); const row = reapingTalent?.rankEffects[selectedRank - 1]; const chance = row?.procChance ?? 0; return Math.max(0, Math.min(100, Number.isFinite(chance) ? chance : 0)); } /** * The hidden Reaping proc aura uses a periodic dummy as its removal timer. * This is not a normal aura duration: each successful proc resets the timer. */ export function wow335DeathKnightReapingAuraPeriodMs(rank: number): number { const selectedRank = Math.max(0, Math.trunc(Number.isFinite(rank) ? rank : 0)); const effect = reapingTalent?.rankEffects[selectedRank - 1]?.effects.find( (candidate) => candidate.auraType === WOW335_DEATH_KNIGHT_REAPING_PROC_AURA_TYPE, ); const periodMs = effect?.periodMs ?? 0; return Math.max(0, Number.isFinite(periodMs) ? periodMs : 0); } export interface Wow335DeathKnightReapingRuneState { /** Base-Blood slots currently converted by the active Reaping proc aura. */ readonly convertedRuneSlots: readonly number[]; /** The aura's shared 30-second removal timer, refreshed by each proc. */ readonly expiresAt: number | null; } export interface Wow335DeathKnightReapingProcPlan { readonly newlyConvertedRuneSlot: number; readonly state: Wow335DeathKnightReapingRuneState; } /** * Plans the stock conversion after a Reaping proc succeeds. Exactly one * original Blood slot that just entered cooldown is selected, in slot order, * matching the core handler. The caller applies/records the plan with its rune * state. The aura-owned removal timer comes from the rank's Periodic Dummy * interval, not from the rune cooldown. */ export function wow335DeathKnightReapingProcPlan( rank: number, state: Wow335DeathKnightReapingRuneState, runesBefore: readonly DeathKnightRuneState[], runesAfter: readonly DeathKnightRuneState[], now: number, ): Wow335DeathKnightReapingProcPlan | null { const auraPeriodMs = wow335DeathKnightReapingAuraPeriodMs(rank); if (auraPeriodMs <= 0 || !Number.isFinite(now)) return null; const afterBySlot = new Map(runesAfter.map((rune) => [rune.slot, rune])); const consumedBloodRune = [...runesBefore] .sort((left, right) => left.slot - right.slot) .find((before) => { const after = afterBySlot.get(before.slot); return before.baseType === "blood" && before.readyAt <= now && after?.baseType === "blood" && after.readyAt > now; }); if (!consumedBloodRune) return null; const activePreviousSlots = state.expiresAt !== null && state.expiresAt > now ? state.convertedRuneSlots : []; const convertedRuneSlots = [...new Set([...activePreviousSlots, consumedBloodRune.slot])] .sort((left, right) => left - right); return Object.freeze({ newlyConvertedRuneSlot: consumedBloodRune.slot, state: Object.freeze({ convertedRuneSlots: Object.freeze(convertedRuneSlots), expiresAt: now + auraPeriodMs, }), }); } export interface Wow335DeathKnightReapingRuneRestorePlan { /** Rune slots whose Death type must be restored to their base Blood type. */ readonly restoredRuneSlots: readonly number[]; readonly state: Wow335DeathKnightReapingRuneState; } /** Extracts the active Reaping-owned rune conversions without claiming Blood Tap runes. */ export function wow335DeathKnightReapingStateForRunes( runes: readonly DeathKnightRuneState[], now: number, ): Wow335DeathKnightReapingRuneState { const conversions = runes.flatMap((rune) => (rune.deathRuneConversions ?? []) .filter((conversion) => conversion.source === "reaping" && Number.isFinite(conversion.expiresAt) && conversion.expiresAt > now) .map((conversion) => ({ slot: rune.slot, expiresAt: conversion.expiresAt }))); return Object.freeze({ convertedRuneSlots: Object.freeze([...new Set(conversions.map((conversion) => conversion.slot))] .sort((left, right) => left - right)), expiresAt: conversions.length ? Math.max(...conversions.map((conversion) => conversion.expiresAt)) : null, }); } /** Applies a planned Reaping conversion while preserving other aura owners. */ export function wow335DeathKnightReapingApplyProcPlan( runes: readonly DeathKnightRuneState[], plan: Wow335DeathKnightReapingProcPlan | null, ): readonly DeathKnightRuneState[] { if (!plan) return runes; const affectedSlots = new Set([...plan.state.convertedRuneSlots]); return Object.freeze(runes.map((rune) => { if (!affectedSlots.has(rune.slot)) return rune; const conversions = [...(rune.deathRuneConversions ?? [])] .filter((conversion) => conversion.source !== "reaping"); conversions.push({ source: "reaping", expiresAt: plan.state.expiresAt! }); const expiresAt = Math.max(...conversions.map((conversion) => conversion.expiresAt)); return Object.freeze({ ...rune, type: "death" as const, deathRuneExpiresAt: expiresAt, deathRuneConversions: Object.freeze(conversions), }); })); } /** Successful landed spending clears only Reaping ownership on the exact spent slots. */ export function wow335DeathKnightReapingRestoreLandedSpend( runes: readonly DeathKnightRuneState[], spentRuneSlots: readonly number[], didLand: boolean, ): readonly DeathKnightRuneState[] { if (!didLand || spentRuneSlots.length === 0) return runes; const spent = new Set(spentRuneSlots); return Object.freeze(runes.map((rune) => { if (!spent.has(rune.slot)) return rune; const conversions = (rune.deathRuneConversions ?? []) .filter((conversion) => conversion.source !== "reaping"); if (conversions.length > 0) { return Object.freeze({ ...rune, type: "death" as const, deathRuneExpiresAt: Math.max(...conversions.map((conversion) => conversion.expiresAt)), deathRuneConversions: Object.freeze(conversions), }); } const { deathRuneExpiresAt: _expiresAt, deathRuneConversions: _conversions, ...baseRune } = rune; void _expiresAt; void _conversions; return Object.freeze({ ...baseRune, type: rune.baseType }); })); } /** Identifies rune slots that became unavailable in this use, ignoring pre-existing cooldowns. */ export function wow335DeathKnightSpentRuneSlots( runesBefore: readonly DeathKnightRuneState[], runesAfter: readonly DeathKnightRuneState[], now: number, ): readonly number[] { const afterBySlot = new Map(runesAfter.map((rune) => [rune.slot, rune])); return Object.freeze(runesBefore .filter((rune) => rune.readyAt <= now && (afterBySlot.get(rune.slot)?.readyAt ?? rune.readyAt) > now) .map((rune) => rune.slot) .sort((left, right) => left - right)); } /** * A successful ability spend restores an aura-converted Death Rune to its * base type immediately. A miss retains the Death type. The caller supplies * the exact spent slots from the committed rune-cost result. */ export function wow335DeathKnightReapingRestoreSpentRunes( state: Wow335DeathKnightReapingRuneState, spentRuneSlots: readonly number[], didHit: boolean, now: number, ): Wow335DeathKnightReapingRuneRestorePlan { if (!Number.isFinite(now) || state.expiresAt === null || state.expiresAt <= now) { return Object.freeze({ restoredRuneSlots: Object.freeze([]), state: Object.freeze({ convertedRuneSlots: Object.freeze([]), expiresAt: null }), }); } if (!didHit) { return Object.freeze({ restoredRuneSlots: Object.freeze([]), state }); } const spent = new Set(spentRuneSlots); const restoredRuneSlots = state.convertedRuneSlots.filter((slot) => spent.has(slot)); if (!restoredRuneSlots.length) { return Object.freeze({ restoredRuneSlots: Object.freeze([]), state }); } const restored = new Set(restoredRuneSlots); const convertedRuneSlots = state.convertedRuneSlots.filter((slot) => !restored.has(slot)); return Object.freeze({ restoredRuneSlots: Object.freeze(restoredRuneSlots), state: Object.freeze({ convertedRuneSlots: Object.freeze(convertedRuneSlots), // The hidden periodic aura remains until its timer ticks even if every // converted rune has already been restored by successful rune spends. expiresAt: state.expiresAt, }), }); } /** Returns Reaping-owned rune slots that the caller must restore at aura expiry. */ export function wow335DeathKnightReapingExpiredRuneSlots( state: Wow335DeathKnightReapingRuneState, now: number, ): readonly number[] { return state.expiresAt !== null && Number.isFinite(now) && now >= state.expiresAt ? state.convertedRuneSlots : Object.freeze([]); } /** Resolves Reaping's generated trigger and chance; caller applies the proc plan. */ export function wow335DeathKnightReapingShouldProc( rank: number, event: { readonly spellId: number; /** True only for a resolved hit; misses and evades must pass false. */ readonly landed: boolean; /** A deterministic roll in [0, 1), supplied by the calling combat path. */ readonly roll: number; }, ): boolean { const chancePercent = wow335DeathKnightReapingProcChancePercent(rank); if (chancePercent <= 0 || !event.landed || !WOW335_DEATH_KNIGHT_REAPING_TRIGGER_SPELL_IDS.includes(event.spellId) || !Number.isFinite(event.roll) || event.roll < 0 || event.roll >= 1) return false; return event.roll < chancePercent / 100; }