Skip to main content

wowlab_engine_content/hooks/devourer_demon_hunter/
hooks.rs

1use wowlab_engine_combat::DamageOps as _;
2use wowlab_engine_combat::CooldownOps as _;
3use wowlab_engine_combat::ResourceOps as _;
4use wowlab_engine_combat::SchedulingOps as _;
5use wowlab_engine_combat::AuraOps as _;
6use wowlab_engine_combat::{DamageFlags, HookCtx, ImpactEvent, MasteryCtx};
7use wowlab_engine_rng::proc_chance;
8use wowlab_types::constants::MS_PER_SECOND;
9
10use super::config::devourer_config;
11use crate::generated::specs::devourer_demon_hunter::{
12    AURA_FEAST_OF_SOULS, AURA_MOMENT_OF_CRAVING, AURA_SOUL_FRAGMENTS, AURA_SOULBURST,
13    AURA_VOID_METAMORPHOSIS, AURA_VOID_METAMORPHOSIS_STACK, AURA_VOIDFALL_BUILDING,
14    AURA_VOIDFALL_FINAL_HOUR, AURA_VOIDFALL_SPENDING, EFFECT, REPORTED_SPELL, SPELL, SPELL_REAP,
15};
16#[cfg(test)]
17use crate::generated::specs::devourer_demon_hunter::HERO;
18use crate::hooks::shared::demon_hunter::{
19    AnnihilatorCoefficient, AnnihilatorDamage, AnnihilatorDamageProfile, AnnihilatorRules,
20    AnnihilatorState, VoidfallSpend, annihilator_meteor_impact, gain_voidfall_building,
21    roll_voidfall, spend_voidfall,
22};
23
24const FURY_PER_SOUL_FRAGMENT: f64 = 4.0;
25const SOULBURST_BLP_BASE: f64 = 0.0663;
26const SOULBURST_BLP_STEP: f64 = 0.0519;
27const SOULBURST_BLP_CAP: f64 = 0.3949;
28
29#[derive(Clone, Copy, Debug, Default)]
30pub(super) struct DevourerRuntime {
31    soulburst_failures: u32,
32}
33
34fn soulburst_chance(failures: u32) -> f64 {
35    (SOULBURST_BLP_BASE + SOULBURST_BLP_STEP * f64::from(failures)).min(SOULBURST_BLP_CAP)
36}
37
38fn trigger_soulburst_roll(ctx: &mut HookCtx<'_>, fragments_consumed: i32) {
39    let cfg = devourer_config(ctx);
40
41    if cfg.mid2_2pc_fragment_threshold <= 0
42        || fragments_consumed < cfg.mid2_2pc_fragment_threshold
43    {
44        return;
45    }
46
47    let failures = ctx
48        .spec_runtime_mut::<DevourerRuntime>()
49        .map_or(0, |runtime| runtime.soulburst_failures);
50
51    let chance = if SOULBURST_BLP_BASE > 0.0 {
52        soulburst_chance(failures)
53    } else {
54        cfg.mid2_2pc_fallback_chance
55    };
56
57    if proc_chance(ctx.rng(), chance) {
58        if let Some(runtime) = ctx.spec_runtime_mut::<DevourerRuntime>() {
59            runtime.soulburst_failures = 0;
60        }
61
62        ctx.apply_aura(AURA_SOULBURST);
63    } else if let Some(runtime) = ctx.spec_runtime_mut::<DevourerRuntime>() {
64        runtime.soulburst_failures = runtime.soulburst_failures.saturating_add(1);
65    }
66}
67
68fn roll_soul_splitter(ctx: &mut HookCtx<'_>) {
69    let chance = devourer_config(ctx).soul_splitter_chance;
70
71    if chance > 0.0 && proc_chance(ctx.rng(), chance) {
72        ctx.apply_aura(AURA_SOUL_FRAGMENTS);
73        ctx.apply_aura(AURA_VOID_METAMORPHOSIS_STACK);
74    }
75}
76
77fn gain_soul_fragment(ctx: &mut HookCtx<'_>) {
78    ctx.apply_aura(AURA_SOUL_FRAGMENTS);
79    ctx.apply_aura(AURA_VOID_METAMORPHOSIS_STACK);
80    roll_soul_splitter(ctx);
81}
82
83const fn annihilator_state() -> AnnihilatorState {
84    AnnihilatorState {
85        proc_driver: wowlab_engine_combat::generated_identities::proc_counter::VOIDFALL_ATTEMPT,
86        building: AURA_VOIDFALL_BUILDING,
87        spending: AURA_VOIDFALL_SPENDING,
88        final_hour: AURA_VOIDFALL_FINAL_HOUR,
89    }
90}
91
92fn annihilator_rules(ctx: &HookCtx<'_>) -> AnnihilatorRules {
93    let cfg = devourer_config(ctx);
94
95    AnnihilatorRules {
96        enabled: cfg.voidfall,
97        proc_chance: cfg.voidfall_chance,
98        building_stacks: cfg.voidfall_stacks,
99        maximum_stacks: cfg.voidfall_max_stacks,
100        meteoric_fall: cfg.meteoric_fall,
101        world_killer: cfg.world_killer,
102    }
103}
104
105fn annihilator_damage(ctx: &HookCtx<'_>) -> AnnihilatorDamage {
106    let cfg = devourer_config(ctx);
107
108    AnnihilatorDamage {
109        meteor: AnnihilatorDamageProfile {
110            action: crate::generated::specs::devourer_demon_hunter::DAMAGE_SPELL::VOIDFALL_METEOR_DAMAGE,
111            coefficient: AnnihilatorCoefficient::SpellPower(cfg.meteor_coef),
112        },
113        world_killer: AnnihilatorDamageProfile {
114            action: crate::generated::specs::devourer_demon_hunter::DAMAGE_SPELL::WORLD_KILLER_DAMAGE,
115            coefficient: AnnihilatorCoefficient::SpellPower(cfg.world_killer_coef),
116        },
117    }
118}
119
120fn spend_annihilator(ctx: &mut HookCtx<'_>) -> VoidfallSpend {
121    let rules = annihilator_rules(ctx);
122    let damage = annihilator_damage(ctx);
123    let spend = spend_voidfall(ctx, annihilator_state(), rules, damage);
124    let souls = spend.world_killers * devourer_config(ctx).world_killer_souls;
125
126    for _ in 0..souls {
127        gain_soul_fragment(ctx);
128    }
129
130    spend
131}
132
133pub(crate) fn reap_hook(ctx: &mut HookCtx<'_>) {
134    let (base_soul_limit, craving_extra_souls, soulshaper_per_fragment, feast_of_souls_enabled) = {
135        let config = devourer_config(ctx);
136
137        (
138            config.reap_soul_limit,
139            config.moment_of_craving_extra_souls,
140            config.soulshaper_per_fragment,
141            config.feast_of_souls_enabled,
142        )
143    };
144    let soul_limit = base_soul_limit
145        + i32::from(ctx.is_aura_active(AURA_MOMENT_OF_CRAVING)) * craving_extra_souls;
146    let souls_consumed = ctx.consume_aura_stacks(AURA_SOUL_FRAGMENTS, soul_limit.max(0));
147
148    trigger_soulburst_roll(ctx, souls_consumed);
149
150    if feast_of_souls_enabled {
151        ctx.apply_aura_n(AURA_FEAST_OF_SOULS, souls_consumed);
152    }
153
154    ctx.gain_resource(f64::from(souls_consumed) * FURY_PER_SOUL_FRAGMENT);
155    let coefficient = ctx.effect_sp_coefficient(crate::generated::specs::devourer_demon_hunter::DAMAGE_EFFECT::REAP_DAMAGE)
156        * (1.0 + f64::from(souls_consumed) * soulshaper_per_fragment);
157
158    ctx.deal_damage_sp(
159        crate::generated::specs::devourer_demon_hunter::DAMAGE_REPORTED::REAP_DAMAGE,
160        coefficient,
161        DamageFlags::empty(),
162    );
163    ctx.consume_aura(AURA_MOMENT_OF_CRAVING);
164    let _ = spend_annihilator(ctx);
165}
166
167fn void_ray_complete_hook(ctx: &mut HookCtx<'_>) {
168    ctx.apply_aura(AURA_MOMENT_OF_CRAVING);
169    ctx.reset_cooldown(SPELL_REAP);
170    let cfg = devourer_config(ctx);
171
172    if cfg.meteoric_rise_stacks > 0 {
173        gain_voidfall_building(
174            ctx,
175            annihilator_state(),
176            cfg.voidfall_max_stacks,
177            cfg.meteoric_rise_stacks,
178        );
179    }
180}
181
182/// Schedules Void Ray's completion riders at the actual hasted channel end.
183pub(crate) fn void_ray_hook(ctx: &mut HookCtx<'_>) {
184    let remaining_seconds = (ctx.buf().player().channel_end - ctx.now().as_secs_f64()).max(0.0);
185    let remaining_ms =
186        wowlab_types::numeric::f64_to_u32_saturating_round(remaining_seconds * MS_PER_SECOND);
187
188    ctx.schedule_hook(remaining_ms, void_ray_complete_hook);
189}
190
191pub(super) fn shattered_souls_fire(
192    ctx: &mut HookCtx<'_>,
193    _impact: ImpactEvent,
194) {
195    gain_soul_fragment(ctx);
196}
197
198pub(crate) fn consume_hook(ctx: &mut HookCtx<'_>) {
199    gain_soul_fragment(ctx);
200    let rules = annihilator_rules(ctx);
201
202    roll_voidfall(ctx, annihilator_state(), rules, 1);
203
204    if ctx.is_aura_active(AURA_SOULBURST) {
205        let coefficient = ctx.effect_sp_coefficient(
206            crate::generated::specs::devourer_demon_hunter::DAMAGE_EFFECT::SOULBURST_DAMAGE,
207        );
208
209        ctx.with_resolved_spell_effect_targets(
210            crate::generated::specs::devourer_demon_hunter::DAMAGE_EFFECT::SOULBURST_DAMAGE,
211            crate::generated::specs::devourer_demon_hunter::DAMAGE_REPORTED::SOULBURST,
212            None,
213            |ctx, targets| {
214                for target in targets.iter().copied() {
215                    ctx.deal_damage_sp_to_target(
216                        target,
217                        crate::generated::specs::devourer_demon_hunter::DAMAGE_REPORTED::SOULBURST,
218                        coefficient,
219                        DamageFlags::AOE,
220                    );
221                }
222            },
223        );
224
225        let cfg = devourer_config(ctx);
226
227        if cfg.mid2_4pc_fragments > 0 {
228            ctx.apply_aura(AURA_MOMENT_OF_CRAVING);
229            ctx.reset_cooldown(SPELL_REAP);
230
231            for _ in 0..cfg.mid2_4pc_fragments {
232                gain_soul_fragment(ctx);
233            }
234        }
235
236        ctx.expire_aura(AURA_SOULBURST);
237    }
238}
239
240/// Applies Eradicate's Annihilator spending rider.
241pub(crate) fn eradicate_hook(ctx: &mut HookCtx<'_>) {
242    let cfg = devourer_config(ctx);
243
244    let soul_limit = cfg.reap_soul_limit
245        + i32::from(ctx.is_aura_active(AURA_MOMENT_OF_CRAVING))
246            * cfg.moment_of_craving_extra_souls;
247    let souls_consumed = ctx.consume_aura_stacks(AURA_SOUL_FRAGMENTS, soul_limit.max(0));
248
249    trigger_soulburst_roll(ctx, souls_consumed);
250
251    if cfg.feast_of_souls_enabled {
252        ctx.apply_aura_n(AURA_FEAST_OF_SOULS, souls_consumed);
253    }
254
255    ctx.gain_resource(f64::from(souls_consumed) * FURY_PER_SOUL_FRAGMENT);
256    ctx.consume_aura(AURA_MOMENT_OF_CRAVING);
257    let _ = spend_annihilator(ctx);
258}
259
260pub(crate) fn void_metamorphosis_hook(ctx: &mut HookCtx<'_>) {
261    ctx.consume_aura(AURA_VOID_METAMORPHOSIS_STACK);
262    let cfg = devourer_config(ctx);
263
264    if cfg.mass_acceleration_stacks > 0 {
265        gain_voidfall_building(
266            ctx,
267            annihilator_state(),
268            cfg.voidfall_max_stacks,
269            cfg.mass_acceleration_stacks,
270        );
271        ctx.reset_cooldown(SPELL_REAP);
272    }
273}
274
275/// Returns Monster Within mastery damage using spell 1238855 e1/e2 affect lists.
276#[must_use]
277pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
278    let cast_of_affected_payload = matches!(
279        ctx.spell_id,
280        SPELL::REAP
281            | SPELL::VOID_RAY
282            | SPELL::CULL
283            | SPELL::ERADICATE
284            | SPELL::HUNGERING_SLASH
285            | SPELL::COLLAPSING_STAR_SPELL
286            | SPELL::THE_HUNT
287    );
288    let effect = if ctx.is_periodic {
289        EFFECT::MONSTER_WITHIN_PERIODIC.1
290    } else {
291        EFFECT::MONSTER_WITHIN_DIRECT.1
292    };
293
294    if cast_of_affected_payload || ctx.affects_spell(effect) {
295        monster_within_multiplier(
296            ctx.bonus(effect),
297            ctx.is_aura_active(AURA_VOID_METAMORPHOSIS),
298        )
299    } else {
300        1.0
301    }
302}
303
304const fn monster_within_multiplier(base_bonus: f64, metamorphosis: bool) -> f64 {
305    if metamorphosis {
306        base_bonus * base_bonus
307    } else {
308        base_bonus
309    }
310}
311
312pub(super) fn shattered_souls_spell_filter(spell_id: u32) -> bool {
313    annihilator_meteor_impact(spell_id)
314        || matches!(
315            spell_id,
316            SPELL::CONSUME
317                | SPELL::DEVOUR
318                | SPELL::VOID_RAY
319                | SPELL::VOID_RAY_TICK
320                | SPELL::REAP
321                | SPELL::ERADICATE
322                | SPELL::HUNGERING_SLASH
323                | SPELL::COLLAPSING_STAR_SPELL
324                | SPELL::SOUL_IMMOLATION_SPELL
325                | SPELL::CULL
326                | REPORTED_SPELL::SOULBURST
327                | REPORTED_SPELL::VOIDBLADE_DAMAGE
328        )
329}
330
331#[cfg(test)]
332mod tests {
333    use googletest::prelude::*;
334    use rstest::rstest;
335
336    use super::*;
337
338    #[gtest]
339    #[rstest]
340    #[case::outside_metamorphosis(1.4, false, 1.4)]
341    #[case::during_metamorphosis(1.4, true, 1.96)]
342    fn monster_within_applies_a_second_copy_during_metamorphosis(
343        #[case] base_bonus: f64,
344        #[case] metamorphosis: bool,
345        #[case] expected: f64,
346    ) -> Result<()> {
347        verify_that!(
348            monster_within_multiplier(base_bonus, metamorphosis),
349            near(expected, 1e-9)
350        )
351    }
352
353    #[gtest]
354    #[rstest]
355    #[case::consume(SPELL::CONSUME)]
356    #[case::devour(SPELL::DEVOUR)]
357    #[case::voidblade(REPORTED_SPELL::VOIDBLADE_DAMAGE)]
358    #[case::void_ray(SPELL::VOID_RAY)]
359    #[case::void_ray_tick(SPELL::VOID_RAY_TICK)]
360    #[case::reap(SPELL::REAP)]
361    #[case::eradicate(SPELL::ERADICATE)]
362    #[case::hungering_slash(SPELL::HUNGERING_SLASH)]
363    #[case::collapsing_star(SPELL::COLLAPSING_STAR_SPELL)]
364    #[case::soul_immolation(SPELL::SOUL_IMMOLATION_SPELL)]
365    #[case::cull(SPELL::CULL)]
366    #[case::voidfall_meteor(HERO::ANNIHILATOR::SPELL::VOIDFALL_METEOR_DAMAGE)]
367    #[case::world_killer(HERO::ANNIHILATOR::SPELL::WORLD_KILLER_DAMAGE)]
368    fn shattered_souls_filter_whitelist_members_pass(#[case] spell_id: u32) -> Result<()> {
369        verify_that!(shattered_souls_spell_filter(spell_id), eq(true))
370    }
371
372    #[gtest]
373    #[rstest]
374    #[case::void_ray_tick_meta(SPELL::VOID_RAY_TICK_META)]
375    #[case::zero(0)]
376    #[case::arbitrary(1)]
377    fn shattered_souls_filter_rejects_non_members(#[case] spell_id: u32) -> Result<()> {
378        verify_that!(shattered_souls_spell_filter(spell_id), eq(false))
379    }
380
381    #[gtest]
382    #[rstest]
383    #[case::first_eligible_harvest(0, SOULBURST_BLP_BASE)]
384    #[case::one_failure(1, SOULBURST_BLP_BASE + SOULBURST_BLP_STEP)]
385    #[case::capped(100, SOULBURST_BLP_CAP)]
386    fn soulburst_bad_luck_protection_uses_linear_ramp_and_cap(
387        #[case] failures: u32,
388        #[case] expected: f64,
389    ) -> Result<()> {
390        verify_that!(soulburst_chance(failures), near(expected, 1e-12))
391    }
392}