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_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
182pub(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
240pub(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#[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}