Skip to main content

wowlab_engine_content/hooks/havoc_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_types::{constants::HUNDRED, constants::MS_PER_SECOND};
7
8use wowlab_engine_combat::{DamageFlags, HookCtx, MasteryCtx, SwingEvent};
9use wowlab_engine_rng::proc_chance;
10
11use crate::generated::specs::havoc_demon_hunter::{
12    AURA_CYCLE_OF_HATRED, AURA_DEMONIC_INTENSITY, AURA_EMPOWERED_EYE_BEAM, AURA_FURIOUS_GAZE,
13    AURA_METAMORPHOSIS, AURA_SERRATED_GLAIVE, AURA_STUDENT_OF_SUFFERING, EFFECT,
14    SPELL_BLADE_DANCE, SPELL_ESSENCE_BREAK, SPELL_EYE_BEAM, SPELL_THROW_GLAIVE,
15};
16
17use super::config::{DemonsurgeAbilities, HavocConfig, HavocRuntime, havoc_config};
18
19/// Metamorphosis base buff duration (162264: 20s), for the Demonic fresh-trigger math.
20const META_BASE_MS: u32 = 20_000;
21
22/// Blade Dance deals three 199552-family slashes before the final hit (188499 e2/e3/e4).
23const BLADE_DANCE_SLASHES: usize = 3;
24
25const DEMONSURGE_DELAY_MS: u32 = 450;
26const DEMONSURGE_DEATH_SWEEP_DELAY_MS: u32 = 700;
27// The Fel-Scarred Demonsurge state tracks at most four empowered abilities.
28const DEMONSURGE_MAX_STACKS: u8 = 4;
29/// Cycle cross-reduction applies only while the pre-increment stack is below four.
30const CYCLE_CROSS_REDUCTION_MAX_STACKS: u32 = 4;
31
32/// Returns Demonic Presence mastery damage.
33///
34/// 185164 e1 (direct) and e2 (periodic) each ship their own 36-spell affect list.
35#[must_use]
36pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
37    ctx.direct_or_periodic_affected_bonus_any(
38        &[EFFECT::MASTERY_DIRECT_BONUS.1],
39        &[EFFECT::MASTERY_PERIODIC_BONUS.1],
40    )
41}
42
43fn apply_serrated_glaive(ctx: &mut HookCtx<'_>) {
44    if havoc_config(ctx).serrated_glaive {
45        ctx.apply_aura(AURA_SERRATED_GLAIVE);
46    }
47}
48
49pub(crate) fn chaos_strike_hook(ctx: &mut HookCtx<'_>) {
50    apply_serrated_glaive(ctx);
51    let refund_chance = ctx.action_proc_chance(
52        crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::CHAOS_STRIKE_REFUND_PROC
53            .action(),
54    );
55
56    if proc_chance(ctx.rng(), refund_chance) {
57        let refund = ctx.effect_base_points(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::CHAOS_STRIKE_FURY_REFUND);
58
59        ctx.gain_resource(refund);
60    }
61}
62
63fn demonsurge_damage_hook(ctx: &mut HookCtx<'_>) {
64    let cfg = havoc_config(ctx);
65    let mult = ctx
66        .spec_runtime_mut::<HavocRuntime>()
67        .map_or(1.0, |runtime| {
68            let mult = 1.0 + cfg.demonsurge_stack_pct * f64::from(runtime.demonsurge_stacks);
69
70            runtime.demonsurge_stacks = runtime
71                .demonsurge_stacks
72                .saturating_add(1)
73                .min(DEMONSURGE_MAX_STACKS);
74
75            mult
76        });
77
78    if cfg.demonsurge {
79        ctx.deal_effect_damage_ap(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::DEMONSURGE_DAMAGE, mult, DamageFlags::empty());
80    }
81}
82
83fn trigger_demonsurge(ctx: &mut HookCtx<'_>, ability: DemonsurgeAbilities, delay_ms: u32) {
84    if !havoc_config(ctx).demonsurge {
85        return;
86    }
87
88    let available = ctx
89        .spec_runtime_mut::<HavocRuntime>()
90        .is_some_and(|runtime| {
91            if !runtime.demonsurge_available.contains(ability) {
92                return false;
93            }
94
95            runtime.demonsurge_available.remove(ability);
96
97            true
98        });
99
100    if available {
101        ctx.schedule_hook(delay_ms, demonsurge_damage_hook);
102    }
103}
104
105/// Annihilation is Chaos Strike's Meta replacement and consumes only its own Demonsurge opportunity.
106pub(crate) fn annihilation_hook(ctx: &mut HookCtx<'_>) {
107    chaos_strike_hook(ctx);
108    trigger_demonsurge(ctx, DemonsurgeAbilities::ANNIHILATION, DEMONSURGE_DELAY_MS);
109}
110
111pub(crate) fn throw_glaive_hook(ctx: &mut HookCtx<'_>) {
112    apply_serrated_glaive(ctx);
113
114    if havoc_config(ctx).furious_throws {
115        ctx.deal_effect_damage_ap(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::FURIOUS_THROWS_DAMAGE, 1.0, DamageFlags::PHYSICAL);
116    }
117}
118
119fn throw_glaive_cd_ready(ctx: &HookCtx<'_>) -> bool {
120    ctx.spell_ready(SPELL_THROW_GLAIVE)
121}
122
123// Screaming Brutality shares Throw Glaive's hasted cooldown from spell 212612 e8.
124fn consume_throw_glaive_cd(ctx: &mut HookCtx<'_>) {
125    ctx.start_cooldown(SPELL_THROW_GLAIVE);
126}
127
128fn screaming_brutality_on_blade_dance(ctx: &mut HookCtx<'_>, cfg: &HavocConfig) {
129    if !cfg.screaming_brutality {
130        return;
131    }
132
133    if throw_glaive_cd_ready(ctx) {
134        consume_throw_glaive_cd(ctx);
135        apply_serrated_glaive(ctx);
136        ctx.deal_effect_damage_ap(
137            crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::THROW_GLAIVE_DAMAGE,
138            cfg.sb_full_pct,
139            DamageFlags::PHYSICAL,
140        );
141
142        if cfg.furious_throws {
143            ctx.deal_effect_damage_ap(
144                crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::FURIOUS_THROWS_DAMAGE,
145                cfg.sb_full_pct,
146                DamageFlags::PHYSICAL,
147            );
148        }
149    }
150
151    for _ in 0..=BLADE_DANCE_SLASHES {
152        if proc_chance(ctx.rng(), cfg.sb_slash_chance) {
153            apply_serrated_glaive(ctx);
154            ctx.deal_effect_damage_ap(
155                crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::THROW_GLAIVE_DAMAGE,
156                cfg.sb_slash_pct,
157                DamageFlags::PHYSICAL,
158            );
159
160            if cfg.furious_throws {
161                ctx.deal_effect_damage_ap(
162                    crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::FURIOUS_THROWS_DAMAGE,
163                    cfg.sb_slash_pct,
164                    DamageFlags::PHYSICAL,
165                );
166            }
167        }
168    }
169}
170
171pub(crate) fn blade_dance_hook(ctx: &mut HookCtx<'_>) {
172    let cfg = havoc_config(ctx);
173
174    screaming_brutality_on_blade_dance(ctx, &cfg);
175
176    if cfg.first_blood {
177        let mult = 1.0 + ctx.effect_base_points(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::FIRST_BLOOD_MULT) / HUNDRED;
178
179        for _ in 0..BLADE_DANCE_SLASHES {
180            ctx.deal_effect_damage_ap(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::FIRST_BLOOD_HIT, mult, DamageFlags::empty());
181        }
182
183        ctx.deal_effect_damage_ap(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::FIRST_BLOOD_FINAL, mult, DamageFlags::empty());
184    } else {
185        for _ in 0..BLADE_DANCE_SLASHES {
186            ctx.deal_effect_damage_ap(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::BLADE_DANCE_HIT, 1.0, DamageFlags::PHYSICAL);
187        }
188
189        ctx.deal_effect_damage_ap(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::BLADE_DANCE_FINAL, 1.0, DamageFlags::PHYSICAL);
190    }
191
192    trigger_trail_of_ruin(ctx, &cfg);
193}
194
195pub(crate) fn death_sweep_hook(ctx: &mut HookCtx<'_>) {
196    let cfg = havoc_config(ctx);
197
198    screaming_brutality_on_blade_dance(ctx, &cfg);
199
200    if cfg.first_blood {
201        let mult = 1.0 + ctx.effect_base_points(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::FIRST_BLOOD_MULT) / HUNDRED;
202
203        for _ in 0..BLADE_DANCE_SLASHES {
204            ctx.deal_effect_damage_ap(
205                crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::DEATH_SWEEP_FIRST_BLOOD_HIT,
206                mult,
207                DamageFlags::empty(),
208            );
209        }
210
211        ctx.deal_effect_damage_ap(
212            crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::DEATH_SWEEP_FIRST_BLOOD_FINAL,
213            mult,
214            DamageFlags::empty(),
215        );
216    }
217
218    trigger_trail_of_ruin(ctx, &cfg);
219
220    trigger_demonsurge(
221        ctx,
222        DemonsurgeAbilities::DEATH_SWEEP,
223        DEMONSURGE_DEATH_SWEEP_DELAY_MS,
224    );
225}
226
227fn trigger_trail_of_ruin(ctx: &mut HookCtx<'_>, cfg: &HavocConfig) {
228    if cfg.trail_of_ruin {
229        ctx.deal_effect_damage_ap(
230            crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::TRAIL_OF_RUIN_DAMAGE,
231            1.0,
232            DamageFlags::empty(),
233        );
234    }
235}
236
237fn eye_beam_channel_ms(ctx: &HookCtx<'_>) -> u32 {
238    let remaining_s = (ctx.buf().player().channel_end - ctx.now().as_secs_f64()).max(0.0);
239
240    wowlab_types::numeric::f64_to_u32_saturating_round(remaining_s * MS_PER_SECOND)
241}
242
243fn trigger_demonic(ctx: &mut HookCtx<'_>, cfg: &HavocConfig) {
244    if !cfg.demonic {
245        return;
246    }
247
248    if cfg.demonsurge
249        && let Some(runtime) = ctx.spec_runtime_mut::<HavocRuntime>() {
250            runtime
251                .demonsurge_available
252                .insert(DemonsurgeAbilities::DEMONIC);
253        }
254
255    let total_ms = cfg.demonic_extension_ms + eye_beam_channel_ms(ctx);
256
257    if ctx.is_aura_active(AURA_METAMORPHOSIS) {
258        ctx.extend_aura(AURA_METAMORPHOSIS, total_ms);
259    } else {
260        ctx.apply_aura(AURA_METAMORPHOSIS);
261        ctx.reduce_aura(
262            AURA_METAMORPHOSIS,
263            META_BASE_MS.saturating_sub(total_ms),
264        );
265    }
266}
267
268fn furious_gaze_apply_hook(ctx: &mut HookCtx<'_>) {
269    ctx.apply_aura(AURA_FURIOUS_GAZE);
270}
271
272fn empowered_eye_beam_expire_hook(ctx: &mut HookCtx<'_>) {
273    ctx.expire_aura(AURA_EMPOWERED_EYE_BEAM);
274}
275
276pub(crate) fn eye_beam_hook(ctx: &mut HookCtx<'_>) {
277    let cfg = havoc_config(ctx);
278
279    if cfg.student_of_suffering {
280        ctx.apply_aura(AURA_STUDENT_OF_SUFFERING);
281    }
282
283    trigger_demonic(ctx, &cfg);
284
285    if cfg.cycle_of_hatred {
286        // The cooldown starts before the Cycle of Hatred stack increments.
287        let stacks = ctx.aura_stacks(AURA_CYCLE_OF_HATRED).max(0);
288
289        if stacks > 0 {
290            let stacks = wowlab_types::numeric::i32_to_u32_nonnegative(stacks);
291
292            ctx.reduce_cooldown(
293                SPELL_EYE_BEAM,
294                cfg.cycle_of_hatred_cdr_ms * stacks,
295            );
296
297            if cfg.mid2_4pc && stacks < CYCLE_CROSS_REDUCTION_MAX_STACKS {
298                ctx.reduce_cooldown(SPELL_ESSENCE_BREAK, cfg.cycle_of_hatred_cdr_ms);
299            }
300        }
301
302        ctx.apply_aura(AURA_CYCLE_OF_HATRED);
303    }
304
305    if cfg.furious_gaze {
306        let channel_ms = eye_beam_channel_ms(ctx);
307
308        ctx.schedule_hook(channel_ms, furious_gaze_apply_hook);
309    }
310
311    if cfg.eternal_hunt && ctx.is_aura_active(AURA_EMPOWERED_EYE_BEAM) {
312        // One millisecond past the nominal end keeps the marker active for the
313        // final channel tick when both events share the same timestamp.
314        ctx.schedule_hook(
315            eye_beam_channel_ms(ctx).saturating_add(1),
316            empowered_eye_beam_expire_hook,
317        );
318    }
319}
320
321pub(crate) fn essence_break_hook(ctx: &mut HookCtx<'_>) {
322    let cfg = havoc_config(ctx);
323
324    if !cfg.mid2_4pc || !cfg.cycle_of_hatred {
325        return;
326    }
327
328    // Both reductions use the pre-increment Cycle stack. The cross-reduction
329    // stops once the fourth stack exists.
330
331    let stacks = wowlab_types::numeric::i32_to_u32_nonnegative(
332        ctx.aura_stacks(AURA_CYCLE_OF_HATRED).max(0),
333    );
334
335    if stacks > 0 {
336        ctx.reduce_cooldown(
337            SPELL_ESSENCE_BREAK,
338            cfg.cycle_of_hatred_cdr_ms * stacks,
339        );
340
341        if stacks < CYCLE_CROSS_REDUCTION_MAX_STACKS {
342            ctx.reduce_cooldown(SPELL_EYE_BEAM, cfg.cycle_of_hatred_cdr_ms);
343        }
344    }
345
346    ctx.apply_aura(AURA_CYCLE_OF_HATRED);
347}
348
349pub(crate) fn abyssal_gaze_hook(ctx: &mut HookCtx<'_>) {
350    eye_beam_hook(ctx);
351    trigger_demonsurge(ctx, DemonsurgeAbilities::ABYSSAL_GAZE, DEMONSURGE_DELAY_MS);
352}
353
354pub(crate) fn eye_beam_tick_hook(ctx: &mut HookCtx<'_>) {
355    let per_tick = havoc_config(ctx).blind_fury_per_tick;
356
357    if per_tick > 0.0 {
358        ctx.gain_resource(per_tick);
359    }
360
361    if ctx.is_aura_active(AURA_EMPOWERED_EYE_BEAM) {
362        // 1271144 e1 (+100% on Eye Beam 1287949) is folded by the generic live-aura path.
363        ctx.deal_effect_damage_ap(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::EMPOWERED_EYE_BEAM_DAMAGE, 1.0, DamageFlags::empty());
364    } else {
365        ctx.deal_effect_damage_ap(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::EYE_BEAM_DAMAGE, 1.0, DamageFlags::empty());
366    }
367}
368
369pub(crate) fn the_hunt_hook(ctx: &mut HookCtx<'_>) {
370    if havoc_config(ctx).eternal_hunt {
371        ctx.apply_aura(AURA_EMPOWERED_EYE_BEAM);
372    }
373}
374
375pub(crate) fn metamorphosis_hook(ctx: &mut HookCtx<'_>) {
376    let cfg = havoc_config(ctx);
377
378    if cfg.demonsurge
379        && let Some(runtime) = ctx.spec_runtime_mut::<HavocRuntime>() {
380            runtime.demonsurge_available = DemonsurgeAbilities::ALL_HARDCAST;
381            runtime.demonsurge_stacks = 0;
382        }
383
384    if cfg.demonic_intensity {
385        ctx.apply_aura(AURA_DEMONIC_INTENSITY);
386    }
387
388    if cfg.chaotic_transformation {
389        ctx.reset_cooldown(SPELL_EYE_BEAM);
390        ctx.reset_cooldown(SPELL_BLADE_DANCE);
391    }
392}
393
394pub(crate) fn consuming_fire_hook(ctx: &mut HookCtx<'_>) {
395    trigger_demonsurge(
396        ctx,
397        DemonsurgeAbilities::CONSUMING_FIRE,
398        DEMONSURGE_DELAY_MS,
399    );
400}
401
402pub(crate) fn swing_hook(ctx: &mut HookCtx<'_>, _event: SwingEvent) {
403    ctx.deal_effect_damage_ap(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::DEMON_BLADES_DAMAGE, 1.0, DamageFlags::empty());
404    let fury = ctx.effect_base_points(crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::DEMON_BLADES_FURY);
405
406    ctx.gain_resource(fury);
407}
408
409#[cfg(test)]
410mod demonic_presence_mastery_tests {
411    use googletest::prelude::*;
412
413    use super::*;
414    use wowlab_engine_ports::{CombatStats};
415use wowlab_engine_gamedata::{SpellProps};
416    use wowlab_types::sim::{EnemyIdx, SimTime, SpellIdx};
417
418    use crate::generated::specs::havoc_demon_hunter::{REPORTED_SPELL, SPELL};
419
420    const DEMON_HUNTER_SPELL_FAMILY: i32 = 107;
421    const DEMONIC_PRESENCE_PCT: f64 = 34.07;
422    const MASTERY_TEST_ROTATION: &str = r#"{"version":1,"name":"presence","variables":{},"actions":[{"type":"wait","seconds":1.0}],"lists":{}}"#;
423
424    /// 185164 e1/e2 list Chaos Strike and Soulscar but not Felblade or Immolation Aura, which are
425    /// Chaos and Fire school hits the deleted school match used to give the full mastery bonus.
426    #[gtest]
427    fn demonic_presence_applies_only_to_its_dbc_affect_list() -> Result<()> {
428        let presence = SpellIdx::from_raw(EFFECT::MASTERY_DIRECT_BONUS.0);
429        let direct = EFFECT::MASTERY_DIRECT_BONUS.1;
430        let periodic = EFFECT::MASTERY_PERIODIC_BONUS.1;
431        let mut data = wowlab_engine_gamedata::ResolvedGameData::builder();
432
433        data.insert_spell_props(
434            presence,
435            SpellProps {
436                mastery_affects_points: true,
437                ..SpellProps::default()
438            },
439        );
440        data.insert_sp_coef(presence, direct, 1.0);
441        data.insert_sp_coef(presence, periodic, 1.0);
442        data.insert_spell_class_flags(presence, DEMON_HUNTER_SPELL_FAMILY, [0, 0, 0, 0]);
443        data.insert_effect_class_mask(presence, direct, [1, 0, 0, 0]);
444        data.insert_effect_class_mask(presence, periodic, [1, 0, 0, 0]);
445
446        for (spells, mask) in [
447            (
448                [
449                    REPORTED_SPELL::CHAOS_STRIKE_DAMAGE_1,
450                    REPORTED_SPELL::SOULSCAR,
451                ],
452                [1, 0, 0, 0],
453            ),
454            (
455                [
456                    REPORTED_SPELL::FELBLADE_DAMAGE,
457                    REPORTED_SPELL::IMMOLATION_AURA_DAMAGE,
458                ],
459                [2, 0, 0, 0],
460            ),
461        ] {
462            for spell in spells {
463                data.insert_spell_class_flags(
464                    SpellIdx::from_raw(spell),
465                    DEMON_HUNTER_SPELL_FAMILY,
466                    mask,
467                );
468            }
469        }
470
471        let built = crate::test_combat_builder_for(wowlab_engine_combat::generated_identities::specs::havoc_demon_hunter::PROOF, CombatStats::default())
472            .game_data(data.build())
473            .effect_identities(wowlab_engine_combat::generated_identities::specs::havoc_demon_hunter::damage::ALL)
474            .mastery_spell(EFFECT::MASTERY_DIRECT_BONUS.0)
475            .mastery_hook(mastery)
476            .spell("felblade", SPELL::FELBLADE, Ok)
477            .into_draft().expect("valid combat draft").finalize(&crate::test_support::rotation_from_json(MASTERY_TEST_ROTATION))
478            .or_fail()?;
479        let bonus = |action, is_periodic: bool| {
480            mastery(
481                &MasteryCtx::new(
482                    &built.program,
483                    &built.instance,
484                    wowlab_engine_combat::MasteryEvent {
485                        action,
486                        school: wowlab_types::combat::DamageSchool::Chaos,
487                        mastery: DEMONIC_PRESENCE_PCT,
488                        player_crit_pct: 0.0,
489                        mastery_action: crate::generated::specs::havoc_demon_hunter::DAMAGE_EFFECT::MASTERY_DIRECT_BONUS.action(),
490                        is_periodic,
491                        is_pet: false,
492                        source: wowlab_types::sim::ActorId::Player,
493                        target: Some(EnemyIdx::PRIMARY),
494                        now: SimTime::ZERO,
495                    },
496                )
497                .expect("mastery fixture coordinates are finalized"),
498            )
499        };
500        let listed = 1.0 + DEMONIC_PRESENCE_PCT / HUNDRED;
501
502        verify_that!(
503            bonus(
504                crate::generated::specs::havoc_demon_hunter::DAMAGE_REPORTED::CHAOS_STRIKE_DAMAGE_1,
505                false,
506            ),
507            near(listed, 1e-12)
508        )?;
509        verify_that!(
510            bonus(
511                crate::generated::specs::havoc_demon_hunter::DAMAGE_REPORTED::SOULSCAR,
512                true,
513            ),
514            near(listed, 1e-12)
515        )?;
516
517        for action in [
518            crate::generated::specs::havoc_demon_hunter::DAMAGE_REPORTED::FELBLADE_DAMAGE,
519            crate::generated::specs::havoc_demon_hunter::DAMAGE_REPORTED::IMMOLATION_AURA_DAMAGE,
520        ] {
521            verify_that!(bonus(action, false), near(1.0, 1e-12))?;
522        }
523
524        Ok(())
525    }
526}
527
528#[cfg(test)]
529#[path = "hooks/serrated_glaive_tests.rs"]
530mod serrated_glaive_tests;