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