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_VOID_METAMORPHOSIS,
13    AURA_VOID_METAMORPHOSIS_STACK, AURA_VOIDFALL_BUILDING, AURA_VOIDFALL_FINAL_HOUR,
14    AURA_VOIDFALL_SPENDING, EFFECT, HERO, REPORTED_SPELL, SPELL, SPELL_REAP,
15};
16use crate::hooks::shared::demon_hunter::{
17    AnnihilatorCoefficient, AnnihilatorDamage, AnnihilatorDamageProfile, AnnihilatorRules,
18    AnnihilatorState, VoidfallSpend, annihilator_meteor_impact, gain_voidfall_building,
19    roll_voidfall, spend_voidfall,
20};
21
22const FURY_PER_SOUL_FRAGMENT: f64 = 4.0;
23
24fn roll_soul_splitter(ctx: &mut HookCtx<'_>) {
25    let chance = devourer_config(ctx).soul_splitter_chance;
26
27    if chance > 0.0 && proc_chance(ctx.rng(), chance) {
28        ctx.apply_aura(AURA_SOUL_FRAGMENTS.raw());
29        ctx.apply_aura(AURA_VOID_METAMORPHOSIS_STACK.raw());
30    }
31}
32
33fn gain_soul_fragment(ctx: &mut HookCtx<'_>) {
34    ctx.apply_aura(AURA_SOUL_FRAGMENTS.raw());
35    ctx.apply_aura(AURA_VOID_METAMORPHOSIS_STACK.raw());
36    roll_soul_splitter(ctx);
37}
38
39const fn annihilator_state() -> AnnihilatorState {
40    AnnihilatorState {
41        proc_driver: HERO::ANNIHILATOR::SPELL::VOIDFALL,
42        building: AURA_VOIDFALL_BUILDING,
43        spending: AURA_VOIDFALL_SPENDING,
44        final_hour: AURA_VOIDFALL_FINAL_HOUR,
45    }
46}
47
48fn annihilator_rules(ctx: &HookCtx<'_>) -> AnnihilatorRules {
49    let cfg = devourer_config(ctx);
50
51    AnnihilatorRules {
52        enabled: cfg.voidfall,
53        proc_chance: cfg.voidfall_chance,
54        building_stacks: cfg.voidfall_stacks,
55        maximum_stacks: cfg.voidfall_max_stacks,
56        meteoric_fall: cfg.meteoric_fall,
57        world_killer: cfg.world_killer,
58    }
59}
60
61fn annihilator_damage(ctx: &HookCtx<'_>) -> AnnihilatorDamage {
62    let cfg = devourer_config(ctx);
63
64    AnnihilatorDamage {
65        meteor: AnnihilatorDamageProfile {
66            spell_id: HERO::ANNIHILATOR::SPELL::VOIDFALL_METEOR_DAMAGE,
67            coefficient: AnnihilatorCoefficient::SpellPower(cfg.meteor_coef),
68        },
69        world_killer: AnnihilatorDamageProfile {
70            spell_id: HERO::ANNIHILATOR::SPELL::WORLD_KILLER_DAMAGE,
71            coefficient: AnnihilatorCoefficient::SpellPower(cfg.world_killer_coef),
72        },
73    }
74}
75
76fn spend_annihilator(ctx: &mut HookCtx<'_>) -> VoidfallSpend {
77    let rules = annihilator_rules(ctx);
78    let damage = annihilator_damage(ctx);
79    let spend = spend_voidfall(ctx, annihilator_state(), rules, damage);
80    let souls = spend.world_killers * devourer_config(ctx).world_killer_souls;
81
82    for _ in 0..souls {
83        gain_soul_fragment(ctx);
84    }
85
86    spend
87}
88
89pub(crate) fn reap_hook(ctx: &mut HookCtx<'_>) {
90    let (base_soul_limit, craving_extra_souls, soulshaper_per_fragment, feast_of_souls_enabled) = {
91        let config = devourer_config(ctx);
92
93        (
94            config.reap_soul_limit,
95            config.moment_of_craving_extra_souls,
96            config.soulshaper_per_fragment,
97            config.feast_of_souls_enabled,
98        )
99    };
100    let soul_limit = base_soul_limit
101        + i32::from(ctx.is_aura_active(AURA_MOMENT_OF_CRAVING.raw())) * craving_extra_souls;
102    let souls_consumed = ctx.consume_aura_stacks(AURA_SOUL_FRAGMENTS.raw(), soul_limit.max(0));
103
104    if feast_of_souls_enabled {
105        ctx.apply_aura_n(AURA_FEAST_OF_SOULS.raw(), souls_consumed);
106    }
107
108    ctx.gain_resource(f64::from(souls_consumed) * FURY_PER_SOUL_FRAGMENT);
109    let coefficient = ctx.effect_sp_coefficient(EFFECT::REAP_DAMAGE)
110        * (1.0 + f64::from(souls_consumed) * soulshaper_per_fragment);
111
112    ctx.deal_damage_sp(
113        REPORTED_SPELL::REAP_DAMAGE,
114        coefficient,
115        DamageFlags::empty(),
116    );
117    ctx.consume_aura(AURA_MOMENT_OF_CRAVING.raw());
118    let _ = spend_annihilator(ctx);
119}
120
121fn void_ray_complete_hook(ctx: &mut HookCtx<'_>) {
122    ctx.apply_aura(AURA_MOMENT_OF_CRAVING.raw());
123    ctx.reset_cooldown(SPELL_REAP.raw());
124    let cfg = devourer_config(ctx);
125
126    if cfg.meteoric_rise_stacks > 0 {
127        gain_voidfall_building(
128            ctx,
129            annihilator_state(),
130            cfg.voidfall_max_stacks,
131            cfg.meteoric_rise_stacks,
132        );
133    }
134}
135
136/// Schedules Void Ray's completion riders at the actual hasted channel end.
137pub(crate) fn void_ray_hook(ctx: &mut HookCtx<'_>) {
138    let remaining_seconds = (ctx.buf().player().channel_end - ctx.now().as_secs_f64()).max(0.0);
139    let remaining_ms =
140        wowlab_types::numeric::f64_to_u32_saturating_round(remaining_seconds * MS_PER_SECOND);
141
142    ctx.schedule_hook(remaining_ms, void_ray_complete_hook);
143}
144
145pub(super) fn shattered_souls_fire(
146    ctx: &mut HookCtx<'_>,
147    _impact: ImpactEvent,
148) {
149    gain_soul_fragment(ctx);
150}
151
152pub(crate) fn consume_hook(ctx: &mut HookCtx<'_>) {
153    gain_soul_fragment(ctx);
154    let rules = annihilator_rules(ctx);
155
156    roll_voidfall(ctx, annihilator_state(), rules, 1);
157}
158
159/// Applies Eradicate's Annihilator spending rider.
160pub(crate) fn eradicate_hook(ctx: &mut HookCtx<'_>) {
161    let _ = spend_annihilator(ctx);
162}
163
164pub(crate) fn void_metamorphosis_hook(ctx: &mut HookCtx<'_>) {
165    ctx.consume_aura(AURA_VOID_METAMORPHOSIS_STACK.raw());
166    let cfg = devourer_config(ctx);
167
168    if cfg.mass_acceleration_stacks > 0 {
169        gain_voidfall_building(
170            ctx,
171            annihilator_state(),
172            cfg.voidfall_max_stacks,
173            cfg.mass_acceleration_stacks,
174        );
175        ctx.reset_cooldown(SPELL_REAP.raw());
176    }
177}
178
179/// Returns Monster Within mastery damage using spell 1238855 e1/e2 affect lists.
180#[must_use]
181pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
182    let cast_of_affected_payload = matches!(
183        ctx.spell_id,
184        SPELL::REAP
185            | SPELL::VOID_RAY
186            | SPELL::CULL
187            | SPELL::ERADICATE
188            | SPELL::HUNGERING_SLASH
189            | SPELL::COLLAPSING_STAR_SPELL
190            | SPELL::THE_HUNT
191    );
192    let effect = if ctx.is_periodic {
193        EFFECT::MONSTER_WITHIN_PERIODIC.1
194    } else {
195        EFFECT::MONSTER_WITHIN_DIRECT.1
196    };
197
198    if cast_of_affected_payload || ctx.affects_spell(effect) {
199        monster_within_multiplier(
200            ctx.bonus(effect),
201            ctx.is_aura_active(AURA_VOID_METAMORPHOSIS),
202        )
203    } else {
204        1.0
205    }
206}
207
208const fn monster_within_multiplier(base_bonus: f64, metamorphosis: bool) -> f64 {
209    if metamorphosis {
210        base_bonus * base_bonus
211    } else {
212        base_bonus
213    }
214}
215
216pub(super) fn shattered_souls_spell_filter(spell_id: u32) -> bool {
217    annihilator_meteor_impact(spell_id)
218        || matches!(
219            spell_id,
220            SPELL::CONSUME
221                | SPELL::DEVOUR
222                | SPELL::VOID_RAY
223                | SPELL::VOID_RAY_TICK
224                | SPELL::REAP
225                | SPELL::ERADICATE
226                | SPELL::HUNGERING_SLASH
227                | SPELL::COLLAPSING_STAR_SPELL
228                | SPELL::SOUL_IMMOLATION_SPELL
229                | SPELL::CULL
230                | REPORTED_SPELL::VOIDBLADE_DAMAGE
231        )
232}
233
234#[cfg(test)]
235mod tests {
236    use googletest::prelude::*;
237    use rstest::rstest;
238
239    use super::*;
240
241    #[gtest]
242    #[rstest]
243    #[case::outside_metamorphosis(1.4, false, 1.4)]
244    #[case::during_metamorphosis(1.4, true, 1.96)]
245    fn monster_within_applies_a_second_copy_during_metamorphosis(
246        #[case] base_bonus: f64,
247        #[case] metamorphosis: bool,
248        #[case] expected: f64,
249    ) -> Result<()> {
250        verify_that!(
251            monster_within_multiplier(base_bonus, metamorphosis),
252            near(expected, 1e-9)
253        )
254    }
255
256    #[gtest]
257    #[rstest]
258    #[case::consume(SPELL::CONSUME)]
259    #[case::devour(SPELL::DEVOUR)]
260    #[case::voidblade(REPORTED_SPELL::VOIDBLADE_DAMAGE)]
261    #[case::void_ray(SPELL::VOID_RAY)]
262    #[case::void_ray_tick(SPELL::VOID_RAY_TICK)]
263    #[case::reap(SPELL::REAP)]
264    #[case::eradicate(SPELL::ERADICATE)]
265    #[case::hungering_slash(SPELL::HUNGERING_SLASH)]
266    #[case::collapsing_star(SPELL::COLLAPSING_STAR_SPELL)]
267    #[case::soul_immolation(SPELL::SOUL_IMMOLATION_SPELL)]
268    #[case::cull(SPELL::CULL)]
269    #[case::voidfall_meteor(HERO::ANNIHILATOR::SPELL::VOIDFALL_METEOR_DAMAGE)]
270    #[case::world_killer(HERO::ANNIHILATOR::SPELL::WORLD_KILLER_DAMAGE)]
271    fn shattered_souls_filter_whitelist_members_pass(#[case] spell_id: u32) -> Result<()> {
272        verify_that!(shattered_souls_spell_filter(spell_id), eq(true))
273    }
274
275    #[gtest]
276    #[rstest]
277    #[case::void_ray_tick_meta(SPELL::VOID_RAY_TICK_META)]
278    #[case::zero(0)]
279    #[case::arbitrary(1)]
280    fn shattered_souls_filter_rejects_non_members(#[case] spell_id: u32) -> Result<()> {
281        verify_that!(shattered_souls_spell_filter(spell_id), eq(false))
282    }
283}