wowlab_engine_content/hooks/devourer_demon_hunter/
hooks.rs1use 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
136pub(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
159pub(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#[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}