Skip to main content

wowlab_engine_content/hooks/feral_druid/
hooks.rs

1use wowlab_engine_combat::DamageOps as _;
2use wowlab_engine_combat::ResourceOps as _;
3use wowlab_engine_combat::SchedulingOps as _;
4use wowlab_engine_combat::AuraOps as _;
5use wowlab_engine_combat::{
6    CombatProgram, DamageFlags, FreeActionSource, HookCtx, ImpactEvent, MasteryCtx, SwingEvent,
7};
8use wowlab_engine_rng::{ppm_proc_chance, proc_chance, roll_range, shuffle_pick};
9use wowlab_types::constants::MS_PER_SECOND;
10
11use crate::generated::specs::feral_druid::{
12    AURA, AURA_APEX_PREDATORS_CRAVING, AURA_BERSERK, AURA_BLOODSEEKER_VINES, AURA_CLEARCASTING,
13    AURA_HALAZZIS_FURY, AURA_INCARNATION_AVATAR_OF_ASHAMANE, AURA_LYCARAS_TEACHINGS_CRIT,
14    AURA_MOONFIRE, AURA_OVERFLOWING_POWER, AURA_RAKE, AURA_RIP, AURA_SABERTOOTH,
15    AURA_SAVAGE_FURY, AURA_STALKING_PREDATOR, AURA_STRATEGIC_INFUSION, AURA_SUDDEN_AMBUSH,
16    AURA_TIGERS_TENACITY,
17    AURA_UNSEEN_PREDATORS_CRAVING, AURA_UNSEEN_SLASH, EFFECT, SPELL, SPELL_FERAL_FRENZY,
18    SPELL_FEROCIOUS_BITE, SPELL_MOONFIRE, SPELL_RAKE, SPELL_SHRED, SPELL_WRATH,
19};
20
21use super::config::{ConvokeCast, FeralConfig, FeralRuntime, feral_config};
22
23/// Rip uses a 3.2-second base duration after spell 400320 e3.
24const MS_PER_CP_RIP: u32 = 3200;
25/// Ferocious Bite burns up to 25 extra energy for up to +100% damage (22568 e2).
26const FB_MAX_EXCESS_ENERGY: f64 = 25.0;
27/// Bite coefficients in DBC are five-combo-point values.
28const MAX_COMBO_POINTS: f64 = 5.0;
29/// Unseen Predator variant 1 (1263657 e1): 15% chance per combo point on Bite to flicker an Unseen Slash.
30const UNSEEN_CHANCE_PER_CP: f64 = 0.15;
31/// Unseen Predator variant 3: +30% Unseen Attack direct damage (1263660 e2).
32const UNSEEN_ATTACK_MULT: f64 = 1.30;
33/// Craving 1263939 lasts one second per combo point.
34const CRAVING_MS_PER_CP: u32 = 1000;
35/// Tiger's Fury grants two Stalking Predator 1263962 stacks.
36const STALKING_PREDATOR_STACKS: i32 = 2;
37/// Tiger's Tenacity readies 3 finisher combo refunds (391872 e1 base 3).
38const TIGERS_TENACITY_STACKS: i32 = 3;
39/// Feral Frenzy claws the target 5 times (274837 e1 base value).
40const FERAL_FRENZY_HITS: u32 = 5;
41/// Feral Frenzy awards 5 combo points (274838 e4 energize * 5 hits).
42const FERAL_FRENZY_CP: f64 = 5.0;
43/// Soul of the Forest: finishers grant 2 energy per combo point (158476 e1).
44const SOTF_ENERGY_PER_CP: f64 = 2.0;
45/// Panther's Guile fill-to-max chance (1280316 proc chance 25%).
46const PANTHERS_GUILE_CHANCE: f64 = 0.25;
47/// Primal Fury: crit combo-point builders grant 1 extra point (16953 e1).
48const PRIMAL_FURY_CP: f64 = 1.0;
49/// Omen of Clarity's rate is absent from DBC and community-tested at 7 PPM.
50const OMEN_OF_CLARITY_PPM: f64 = 7.0;
51const GAUSSIAN_MAX_ATTEMPTS: u8 = 8;
52const BOX_MULLER_LOG_SCALE: f64 = -2.0;
53const UNIT_INTERVAL_MIN: f64 = 0.0;
54const UNIT_INTERVAL_MAX: f64 = 1.0;
55const EXCEPTIONAL_DECK_SHUFFLE_CHANCE: f64 = 0.5;
56const GUIDED_OFFSPEC_MIN: f64 = 2.5;
57const STANDARD_OFFSPEC_MIN: f64 = 4.0;
58const OFFSPEC_RANGE_WIDTH: f64 = 5.0;
59const GUIDED_BITE_MEAN: f64 = 3.0;
60const GUIDED_BITE_STANDARD_DEVIATION: f64 = 0.8;
61const STANDARD_BITE_MEAN: f64 = 4.2;
62const STANDARD_BITE_STANDARD_DEVIATION: f64 = 0.936_089_005_5;
63const CONVOKE_HEAL_WEIGHT: f64 = 0.35;
64const CONVOKE_MOONFIRE_WEIGHT: f64 = 0.5;
65const CONVOKE_WRATH_WEIGHT: f64 = 0.15;
66const CONVOKE_SHRED_WEIGHT: f64 = 0.10;
67const CONVOKE_RAKE_WEIGHT: f64 = 0.22;
68const CONVOKE_OFFSPEC_ACTIONS: [(Option<wowlab_engine_combat::LocalSpellIdx>, f64); 3] = [
69    (None, CONVOKE_HEAL_WEIGHT),
70    (Some(SPELL_MOONFIRE), CONVOKE_MOONFIRE_WEIGHT),
71    (Some(SPELL_WRATH), CONVOKE_WRATH_WEIGHT),
72];
73const CONVOKE_SPEC_ACTIONS: [(Option<wowlab_engine_combat::LocalSpellIdx>, f64); 2] = [
74    (Some(SPELL_SHRED), CONVOKE_SHRED_WEIGHT),
75    (Some(SPELL_RAKE), CONVOKE_RAKE_WEIGHT),
76];
77
78pub(super) const fn builder_filter(spell_id: u32) -> bool {
79    matches!(
80        spell_id,
81        SPELL::SHRED | SPELL::RAKE | SPELL::MOONFIRE | SPELL::SWIPE
82    )
83}
84
85pub(super) const fn rip_filter(spell_id: u32) -> bool {
86    spell_id == SPELL::RIP
87}
88
89pub(super) const fn rip_or_rake_filter(spell_id: u32) -> bool {
90    spell_id == SPELL::RIP || spell_id == AURA::RAKE
91}
92
93pub(super) fn thriving_growth_contribution(program: &CombatProgram, impact: ImpactEvent) -> f64 {
94    let Some(config) = program.spec_config::<FeralConfig>() else {
95        return 0.0;
96    };
97
98    match impact.spell_id {
99        SPELL::RIP => config.thriving_rip_contribution,
100        AURA::RAKE => config.thriving_rake_contribution,
101        _ => 0.0,
102    }
103}
104
105pub(super) fn bloodseeker_vines_fire(
106    ctx: &mut HookCtx<'_>,
107    _impact: ImpactEvent,
108) {
109    ctx.apply_aura(AURA_BLOODSEEKER_VINES);
110}
111
112pub(super) fn apex_predator_fire(
113    ctx: &mut HookCtx<'_>,
114    _impact: ImpactEvent,
115) {
116    ctx.apply_aura(AURA_APEX_PREDATORS_CRAVING);
117}
118
119pub(super) fn primal_fury_fire(
120    ctx: &mut HookCtx<'_>,
121    _impact: ImpactEvent,
122) {
123    gain_cp_overflowing(ctx, PRIMAL_FURY_CP);
124}
125
126fn gain_cp_overflowing(ctx: &mut HookCtx<'_>, amount: f64) {
127    let head_room = MAX_COMBO_POINTS - ctx.secondary_resource();
128    let over = amount - head_room;
129
130    ctx.gain_secondary_resource(amount);
131
132    if over > 0.0 && ctx.is_aura_active(AURA_BERSERK) {
133        for _ in 0..wowlab_types::numeric::f64_to_u32_saturating_ceil(over) {
134            ctx.add_aura_stack(AURA_OVERFLOWING_POWER);
135        }
136    }
137}
138
139fn finisher_effects(ctx: &mut HookCtx<'_>, cp: f64) {
140    let config = feral_config(ctx);
141
142    if cp > 0.0
143        && (ctx.is_aura_active(AURA_BERSERK)
144            || ctx.is_aura_active(AURA_INCARNATION_AVATAR_OF_ASHAMANE))
145    {
146        let duration_ms = halazzis_fury_increment_ms(
147            cp,
148            config.halazzis_fury_ms_per_cp,
149            config.halazzis_fury_guided_ms_per_cp,
150            ctx.is_aura_active(AURA_INCARNATION_AVATAR_OF_ASHAMANE)
151                && config.ashamanes_guidance,
152        );
153
154        if let Some(runtime) = ctx.spec_runtime_mut::<FeralRuntime>() {
155            runtime.halazzis_fury_duration_ms = runtime
156                .halazzis_fury_duration_ms
157                .saturating_add(duration_ms);
158        }
159    }
160
161    if config.soul_of_the_forest {
162        ctx.gain_resource(SOTF_ENERGY_PER_CP * cp);
163    }
164
165    if proc_chance(ctx.rng(), config.sudden_ambush_chance * cp) {
166        ctx.apply_aura(AURA_SUDDEN_AMBUSH);
167    }
168
169    if ctx.consume_aura_stack(AURA_TIGERS_TENACITY) {
170        ctx.gain_secondary_resource(1.0);
171    }
172
173    let op_stacks = ctx.aura_stacks(AURA_OVERFLOWING_POWER);
174
175    if op_stacks > 0 {
176        ctx.expire_aura(AURA_OVERFLOWING_POWER);
177        ctx.gain_secondary_resource(f64::from(op_stacks));
178    }
179}
180
181fn halazzis_fury_increment_ms(
182    effective_cp: f64,
183    normal_ms_per_cp: u32,
184    guided_ms_per_cp: u32,
185    guided_incarnation: bool,
186) -> u32 {
187    let per_cp_ms = if guided_incarnation {
188        guided_ms_per_cp
189    } else {
190        normal_ms_per_cp
191    };
192
193    wowlab_types::numeric::f64_to_u32_saturating_trunc(
194        effective_cp.max(0.0) * f64::from(per_cp_ms),
195    )
196}
197
198/// Returns Razor Claws mastery damage from spell 77493's direct and periodic affect lists.
199#[must_use]
200pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
201    ctx.direct_or_periodic_affected_bonus_any(
202        &[
203            EFFECT::RAZOR_CLAWS_DAMAGE.1,
204            EFFECT::RAZOR_CLAWS_RAMPANT_DIRECT.1,
205        ],
206        &[
207            EFFECT::RAZOR_CLAWS_PERIODIC.1,
208            EFFECT::RAZOR_CLAWS_RAMPANT_PERIODIC.1,
209            EFFECT::RAZOR_CLAWS_BLOODSEEKER_PERIODIC.1,
210            EFFECT::RAZOR_CLAWS_DREADFUL_WOUND_PERIODIC.1,
211            EFFECT::RAZOR_CLAWS_UNSEEN_SLASH_PERIODIC.1,
212        ],
213    )
214}
215
216fn unseen_attack(ctx: &mut HookCtx<'_>, cp: f64, energy_mod: f64) {
217    let scale = (cp / MAX_COMBO_POINTS) * energy_mod;
218    let coef = ctx.effect_ap_coefficient(crate::generated::specs::feral_druid::DAMAGE_EFFECT::UNSEEN_SLASH_DMG) * UNSEEN_ATTACK_MULT * scale;
219
220    ctx.deal_damage_ap(crate::generated::specs::feral_druid::DAMAGE_EFFECT::UNSEEN_SLASH_DMG.action(), coef, DamageFlags::PHYSICAL);
221    ctx.apply_aura(AURA_UNSEEN_SLASH);
222
223    ctx.apply_aura(AURA_UNSEEN_PREDATORS_CRAVING);
224    let extra_cp =
225        wowlab_types::numeric::f64_to_u32_saturating_trunc(cp.max(1.0)).saturating_sub(1);
226
227    if extra_cp > 0 {
228        ctx.extend_aura(
229            AURA_UNSEEN_PREDATORS_CRAVING,
230            extra_cp * CRAVING_MS_PER_CP,
231        );
232    }
233}
234
235pub(crate) fn swing_hook(
236    ctx: &mut HookCtx<'_>,
237    event: SwingEvent,
238) {
239    let hand = event.hand;
240
241    let config = feral_config(ctx);
242
243    if !config.omen_of_clarity {
244        return;
245    }
246
247    let swing_period_seconds = ctx
248        .player_swing_period_ms(hand)
249        .map_or(0.0, |period_ms| f64::from(period_ms) / MS_PER_SECOND);
250    let ppm = OMEN_OF_CLARITY_PPM * (1.0 + config.moment_of_clarity_proc_bonus);
251
252    if proc_chance(ctx.rng(), ppm_proc_chance(ppm, swing_period_seconds)) {
253        ctx.apply_aura(AURA_CLEARCASTING);
254    }
255}
256
257pub(crate) fn halazzis_fury_expire_hook(ctx: &mut HookCtx<'_>) {
258    if ctx.is_aura_active(AURA_BERSERK)
259        || ctx.is_aura_active(AURA_INCARNATION_AVATAR_OF_ASHAMANE)
260    {
261        return;
262    }
263
264    let duration_ms = ctx
265        .spec_runtime_mut::<FeralRuntime>()
266        .map_or(0, |runtime| {
267            std::mem::take(&mut runtime.halazzis_fury_duration_ms)
268        });
269
270    if duration_ms > 0 {
271        ctx.apply_aura_with_duration(AURA_HALAZZIS_FURY, duration_ms);
272    }
273}
274
275pub(crate) fn cat_form_hook(ctx: &mut HookCtx<'_>) {
276    if feral_config(ctx).lycaras_teachings {
277        ctx.apply_aura(AURA_LYCARAS_TEACHINGS_CRIT);
278    }
279}
280
281pub(crate) fn builder_hook(ctx: &mut HookCtx<'_>) {
282    if ctx.is_aura_active(AURA_BERSERK) {
283        gain_cp_overflowing(ctx, 1.0);
284    }
285
286    if ctx.consume_aura_stack(AURA_STALKING_PREDATOR) {
287        unseen_attack(ctx, MAX_COMBO_POINTS, 1.0);
288    }
289}
290
291pub(crate) fn rake_hook(ctx: &mut HookCtx<'_>) {
292    builder_hook(ctx);
293}
294
295fn panthers_guile(ctx: &mut HookCtx<'_>) {
296    if !feral_config(ctx).panthers_guile {
297        return;
298    }
299
300    let missing = MAX_COMBO_POINTS - ctx.secondary_resource();
301
302    if missing > 0.0 && proc_chance(ctx.rng(), PANTHERS_GUILE_CHANCE) {
303        ctx.gain_secondary_resource(missing);
304    }
305}
306
307pub(crate) fn moonfire_hook(ctx: &mut HookCtx<'_>) {
308    builder_hook(ctx);
309    panthers_guile(ctx);
310}
311
312pub(crate) fn tigers_fury_hook(ctx: &mut HookCtx<'_>) {
313    ctx.apply_aura_n(AURA_STALKING_PREDATOR, STALKING_PREDATOR_STACKS);
314    ctx.apply_aura(AURA_SAVAGE_FURY);
315
316    if feral_config(ctx).tigers_tenacity {
317        ctx.apply_aura_n(AURA_TIGERS_TENACITY, TIGERS_TENACITY_STACKS);
318    }
319
320    if feral_config(ctx).strategic_infusion {
321        ctx.apply_aura(AURA_STRATEGIC_INFUSION);
322    }
323}
324
325pub(crate) fn shred_hook(ctx: &mut HookCtx<'_>) {
326    builder_hook(ctx);
327    panthers_guile(ctx);
328    ctx.consume_aura_stack(AURA_CLEARCASTING);
329}
330
331pub(crate) fn rip_hook(ctx: &mut HookCtx<'_>) {
332    let spent_cp = ctx.last_secondary_spent().max(0.0);
333    let cp = if ctx.free_action_source() == Some(FreeActionSource::ChannelChild) {
334        MAX_COMBO_POINTS
335    } else {
336        spent_cp
337    };
338    let whole_cp = wowlab_types::numeric::f64_to_u32_saturating_trunc(cp);
339
340    if whole_cp > 0 {
341        ctx.extend_aura(AURA_RIP, whole_cp * MS_PER_CP_RIP);
342    }
343
344    finisher_effects(ctx, cp);
345}
346
347pub(crate) fn ferocious_bite_hook(ctx: &mut HookCtx<'_>) {
348    let apex = ctx.is_aura_active(AURA_APEX_PREDATORS_CRAVING);
349    let free = ctx.free_action_source() == Some(FreeActionSource::ChannelChild);
350    let spent_cp = ctx.last_secondary_spent().max(0.0);
351    let cp = if apex || free {
352        MAX_COMBO_POINTS
353    } else {
354        spent_cp.max(1.0)
355    };
356
357    finisher_effects(ctx, cp);
358
359    if feral_config(ctx).sabertooth {
360        ctx.apply_aura_n(
361            AURA_SABERTOOTH,
362            wowlab_types::numeric::f64_to_i32_saturating_trunc(cp),
363        );
364    }
365
366    let excess = if apex || free {
367        FB_MAX_EXCESS_ENERGY
368    } else {
369        ctx.last_optional_primary_spent()
370            .clamp(0.0, FB_MAX_EXCESS_ENERGY)
371    };
372    let energy_mod = excess / FB_MAX_EXCESS_ENERGY;
373    let cp_mod = cp / MAX_COMBO_POINTS;
374
375    let coef = ctx.effect_ap_coefficient(crate::generated::specs::feral_druid::DAMAGE_EFFECT::FEROCIOUS_BITE_DMG) * cp_mod * (1.0 + energy_mod);
376
377    ctx.deal_damage_ap(
378        crate::generated::specs::feral_druid::DAMAGE_EFFECT::FEROCIOUS_BITE_DMG.action(),
379        coef,
380        DamageFlags::PHYSICAL,
381    );
382
383    if proc_chance(ctx.rng(), UNSEEN_CHANCE_PER_CP * cp) {
384        unseen_attack(ctx, cp, energy_mod);
385    }
386
387    if apex && !free {
388        ctx.consume_aura_stack(AURA_APEX_PREDATORS_CRAVING);
389    }
390}
391
392pub(crate) fn feral_frenzy_hook(ctx: &mut HookCtx<'_>) {
393    let hit = ctx.effect_ap_coefficient(crate::generated::specs::feral_druid::DAMAGE_EFFECT::FERAL_FRENZY_HIT);
394
395    for _ in 0..FERAL_FRENZY_HITS {
396        ctx.deal_damage_ap(crate::generated::specs::feral_druid::DAMAGE_EFFECT::FERAL_FRENZY_HIT.action(), hit, DamageFlags::PHYSICAL);
397    }
398
399    ctx.gain_secondary_resource(FERAL_FRENZY_CP);
400}
401
402fn bounded_gaussian_count(
403    rng: &mut dyn FnMut() -> f64,
404    mean: f64,
405    standard_deviation: f64,
406    maximum: usize,
407) -> usize {
408    if maximum == 0 {
409        return 0;
410    }
411
412    let maximum_f64 = f64::from(wowlab_types::numeric::usize_to_u32_saturating(maximum));
413
414    for _ in 0..GAUSSIAN_MAX_ATTEMPTS {
415        let u1 = roll_range(rng, f64::MIN_POSITIVE, UNIT_INTERVAL_MAX);
416        let u2 = roll_range(rng, UNIT_INTERVAL_MIN, UNIT_INTERVAL_MAX);
417        let standard_normal =
418            (BOX_MULLER_LOG_SCALE * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
419        let sample = mean + standard_deviation * standard_normal;
420
421        if (0.0..=maximum_f64).contains(&sample) {
422            return wowlab_types::numeric::f64_to_usize_saturating_trunc(sample);
423        }
424    }
425
426    wowlab_types::numeric::f64_to_usize_saturating_trunc(mean.clamp(0.0, maximum_f64))
427}
428
429fn convoke_exceptional_roll(ctx: &mut HookCtx<'_>) -> bool {
430    let mut deck = std::mem::take(
431        &mut ctx
432            .spec_runtime_mut::<FeralRuntime>()
433            .expect("Feral runtime registered")
434            .convoke_exceptional_deck,
435    );
436
437    if deck.is_empty() {
438        deck.extend([true, false]);
439
440        if proc_chance(ctx.rng(), EXCEPTIONAL_DECK_SHUFFLE_CHANCE) {
441            deck.swap(0, 1);
442        }
443    }
444
445    let result = deck.pop().unwrap_or(false);
446
447    ctx.spec_runtime_mut::<FeralRuntime>()
448        .expect("Feral runtime registered")
449        .convoke_exceptional_deck = deck;
450
451    result
452}
453
454pub(crate) fn convoke_cast_hook(ctx: &mut HookCtx<'_>) {
455    let max_ticks = usize::from(ctx.active_channel_ticks_remaining());
456
457    if max_ticks == 0 {
458        return;
459    }
460
461    let guidance = feral_config(ctx).ashamanes_guidance;
462    let offspec_min = if guidance {
463        GUIDED_OFFSPEC_MIN
464    } else {
465        STANDARD_OFFSPEC_MIN
466    };
467    let offspec_draw = wowlab_types::numeric::f64_to_usize_saturating_trunc(roll_range(
468        ctx.rng(),
469        offspec_min,
470        offspec_min + OFFSPEC_RANGE_WIDTH,
471    ));
472    let exceptional = convoke_exceptional_roll(ctx);
473    let offspec_count = offspec_draw.min(max_ticks.saturating_sub(usize::from(exceptional)));
474    let occupied = offspec_count + usize::from(exceptional);
475    let open = max_ticks.saturating_sub(occupied);
476    let bites = bounded_gaussian_count(
477        ctx.rng(),
478        if guidance {
479            GUIDED_BITE_MEAN
480        } else {
481            STANDARD_BITE_MEAN
482        },
483        if guidance {
484            GUIDED_BITE_STANDARD_DEVIATION
485        } else {
486            STANDARD_BITE_STANDARD_DEVIATION
487        },
488        open,
489    );
490    let mut casts = std::mem::take(
491        &mut ctx
492            .spec_runtime_mut::<FeralRuntime>()
493            .expect("Feral runtime registered")
494            .convoke_casts,
495    );
496
497    casts.clear();
498
499    if casts.capacity() < max_ticks {
500        casts.reserve(max_ticks);
501    }
502
503    casts.extend(std::iter::repeat_n(ConvokeCast::Offspec, offspec_count));
504
505    if exceptional {
506        casts.push(ConvokeCast::Exceptional);
507    }
508
509    casts.extend(std::iter::repeat_n(ConvokeCast::Main, bites));
510    casts.resize(max_ticks, ConvokeCast::Spec);
511    ctx.spec_runtime_mut::<FeralRuntime>()
512        .expect("Feral runtime registered")
513        .convoke_casts = casts;
514}
515
516fn convoke_weighted_action(
517    rng: &mut dyn FnMut() -> f64,
518    actions: &[(Option<wowlab_engine_combat::LocalSpellIdx>, f64)],
519) -> Option<wowlab_engine_combat::LocalSpellIdx> {
520    let total: f64 = actions.iter().map(|(_, weight)| weight).sum();
521    let roll = roll_range(rng, UNIT_INTERVAL_MIN, total);
522    let mut cumulative = 0.0;
523
524    for &(spell, weight) in actions {
525        cumulative += weight;
526
527        if roll < cumulative {
528            return spell;
529        }
530    }
531
532    None
533}
534
535pub(crate) fn convoke_tick_hook(ctx: &mut HookCtx<'_>) {
536    let cast = {
537        let mut casts = std::mem::take(
538            &mut ctx
539                .spec_runtime_mut::<FeralRuntime>()
540                .expect("Feral runtime registered")
541                .convoke_casts,
542        );
543
544        if casts.is_empty() {
545            return;
546        }
547
548        shuffle_pick(ctx.rng(), &mut casts, 1);
549        let cast = casts.swap_remove(0);
550
551        ctx.spec_runtime_mut::<FeralRuntime>()
552            .expect("Feral runtime registered")
553            .convoke_casts = casts;
554
555        cast
556    };
557    let spell_id = match cast {
558        ConvokeCast::Exceptional => Some(SPELL_FERAL_FRENZY),
559        ConvokeCast::Main => Some(SPELL_FEROCIOUS_BITE),
560        ConvokeCast::Offspec => convoke_weighted_action(ctx.rng(), &CONVOKE_OFFSPEC_ACTIONS),
561        ConvokeCast::Spec => convoke_weighted_action(ctx.rng(), &CONVOKE_SPEC_ACTIONS),
562    };
563    let Some(mut spell_id) = spell_id else {
564        return;
565    };
566
567    if spell_id == SPELL_MOONFIRE && ctx.is_aura_active(AURA_MOONFIRE) {
568        spell_id = SPELL_WRATH;
569    } else if spell_id == SPELL_RAKE && ctx.is_aura_active(AURA_RAKE) {
570        spell_id = SPELL_SHRED;
571    }
572
573    let _ = ctx.dispatch_free_action(spell_id, FreeActionSource::ChannelChild);
574}
575
576#[cfg(test)]
577#[path = "hooks/tests.rs"]
578mod tests;