Skip to main content

wowlab_engine_content/hooks/vengeance_demon_hunter/
hooks.rs

1use wowlab_engine_combat::DamageOps as _;
2use wowlab_engine_combat::CooldownOps as _;
3use wowlab_engine_combat::AuraOps as _;
4use wowlab_engine_combat::{DamageFlags, HookCtx, MasteryCtx};
5use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
6use wowlab_engine_rng::proc_chance;
7
8use super::config::{AnnihilatorConfig, AnnihilatorRuntime, annihilator_config};
9use crate::hooks::shared::demon_hunter::{
10    AnnihilatorCoefficient, AnnihilatorDamage, AnnihilatorDamageProfile, AnnihilatorRules,
11    AnnihilatorState, gain_voidfall_building, roll_voidfall, spend_voidfall,
12};
13
14use crate::generated::specs::vengeance_demon_hunter::{
15    AURA_DARK_MATTER, AURA_SEETHING_ANGER, AURA_SOUL_FRAGMENTS, AURA_UNTETHERED_RAGE,
16    AURA_VOIDFALL_BUILDING, AURA_VOIDFALL_FINAL_HOUR, AURA_VOIDFALL_SPENDING, EFFECT, HERO,
17    SPELL_METAMORPHOSIS, SPELL_SPIRIT_BOMB, TALENT,
18};
19
20/// Soul Cleave consumes up to two Soul Fragments.
21const SOUL_CLEAVE_FRAGMENTS: i32 = 2;
22
23#[rustfmt::skip]
24pub(super) const UNTETHERED_RAGE_TALENTS: [u32; 3] = [
25    TALENT::UNTETHERED_RAGE_1,
26    TALENT::UNTETHERED_RAGE_2,
27    TALENT::UNTETHERED_RAGE_3,
28];
29/// Untethered Rage uses an approximated 0.75% chance per consumed soul.
30pub(super) const UNTETHERED_RAGE_CHANCE_PER_SOUL: f64 = 0.0075;
31const UNTETHERED_RAGE_GROWTH: f64 = 1.35;
32
33const fn annihilator_state() -> AnnihilatorState {
34    AnnihilatorState {
35        proc_driver: HERO::ANNIHILATOR::SPELL::VOIDFALL,
36        building: AURA_VOIDFALL_BUILDING,
37        spending: AURA_VOIDFALL_SPENDING,
38        final_hour: AURA_VOIDFALL_FINAL_HOUR,
39    }
40}
41
42const fn annihilator_rules(cfg: &AnnihilatorConfig) -> AnnihilatorRules {
43    AnnihilatorRules {
44        enabled: cfg.voidfall,
45        proc_chance: cfg.voidfall_chance,
46        building_stacks: cfg.voidfall_stacks,
47        maximum_stacks: cfg.voidfall_max_stacks,
48        meteoric_fall: cfg.meteoric_fall,
49        world_killer: cfg.world_killer,
50    }
51}
52
53const fn annihilator_damage(cfg: &AnnihilatorConfig) -> AnnihilatorDamage {
54    AnnihilatorDamage {
55        meteor: AnnihilatorDamageProfile {
56            spell_id: HERO::ANNIHILATOR::SPELL::VOIDFALL_METEOR_DAMAGE,
57            coefficient: AnnihilatorCoefficient::AttackPower(cfg.meteor_coef),
58        },
59        world_killer: AnnihilatorDamageProfile {
60            spell_id: HERO::ANNIHILATOR::SPELL::WORLD_KILLER_DAMAGE,
61            coefficient: AnnihilatorCoefficient::AttackPower(cfg.world_killer_coef),
62        },
63    }
64}
65
66fn spend_annihilator(ctx: &mut HookCtx<'_>, cfg: &AnnihilatorConfig) {
67    let spend = spend_voidfall(
68        ctx,
69        annihilator_state(),
70        annihilator_rules(cfg),
71        annihilator_damage(cfg),
72    );
73
74    if spend.world_killers > 0 {
75        ctx.reduce_cooldown(SPELL_METAMORPHOSIS.raw(), cfg.world_killer_meta_cdr_ms);
76    }
77}
78
79fn roll_untethered_rage(ctx: &mut HookCtx<'_>, cfg: &AnnihilatorConfig, souls: i32) {
80    if souls <= 0 || cfg.ur_chance_per_soul <= 0.0 {
81        return;
82    }
83
84    let seething = ctx.aura_stacks(AURA_SEETHING_ANGER.raw());
85    let chance = f64::from(souls) * cfg.ur_chance_per_soul * UNTETHERED_RAGE_GROWTH.powi(seething);
86
87    if proc_chance(ctx.rng(), chance) {
88        ctx.expire_aura(AURA_SEETHING_ANGER.raw());
89        ctx.apply_aura(AURA_UNTETHERED_RAGE.raw());
90        ctx.reset_cooldown(SPELL_METAMORPHOSIS.raw());
91    } else if cfg.ur_seething && !ctx.is_aura_active(AURA_UNTETHERED_RAGE.raw()) {
92        ctx.apply_aura(AURA_SEETHING_ANGER.raw());
93    }
94}
95
96/// Handles Fracture's fragments and Voidfall roll.
97pub(crate) fn fracture_hook(ctx: &mut HookCtx<'_>) {
98    let fragments = ctx
99        .game_data()
100        .effect_base_points(EFFECT::FRACTURE_FRAGMENTS);
101
102    #[expect(
103        clippy::cast_possible_truncation,
104        reason = "DBC base points are bounded to two fragments"
105    )]
106    ctx.apply_aura_n(AURA_SOUL_FRAGMENTS.raw(), fragments as i32);
107
108    let cfg = annihilator_config(ctx);
109
110    roll_voidfall(ctx, annihilator_state(), annihilator_rules(&cfg), 1);
111}
112
113/// Handles Soul Cleave's fragment-consuming riders.
114pub(crate) fn soul_cleave_hook(ctx: &mut HookCtx<'_>) {
115    let mut consumed = 0i32;
116
117    for _ in 0..SOUL_CLEAVE_FRAGMENTS {
118        if !ctx.consume_aura_stack(AURA_SOUL_FRAGMENTS.raw()) {
119            break;
120        }
121
122        consumed += 1;
123    }
124
125    let cfg = annihilator_config(ctx);
126
127    roll_untethered_rage(ctx, &cfg, consumed);
128    // Meteor travel keeps builder rolls blocked for this cast.
129    let was_spending = ctx.is_aura_active(AURA_VOIDFALL_SPENDING.raw());
130
131    spend_annihilator(ctx, &cfg);
132
133    if !was_spending {
134        roll_voidfall(ctx, annihilator_state(), annihilator_rules(&cfg), consumed);
135    }
136}
137
138/// Handles Spirit Bomb's fragment, Dark Matter, and Voidfall riders.
139pub(crate) fn spirit_bomb_hook(ctx: &mut HookCtx<'_>) {
140    let cfg = annihilator_config(ctx);
141    let fragments = ctx.aura_stacks(AURA_SOUL_FRAGMENTS.raw());
142
143    if fragments > 0 {
144        let coef = ctx
145            .game_data()
146            .effect_ap_coefficient(EFFECT::SPIRIT_BOMB_DAMAGE);
147
148        ctx.deal_damage_ap(
149            EFFECT::SPIRIT_BOMB_DAMAGE.0,
150            coef * f64::from(fragments) * cfg.spirit_bomb_mult,
151            DamageFlags::empty(),
152        );
153        ctx.expire_aura(AURA_SOUL_FRAGMENTS.raw());
154    }
155
156    // Dark Matter's meteor shower is collapsed to impact time.
157
158    if cfg.dark_matter && ctx.consume_aura(AURA_DARK_MATTER.raw()) {
159        for _ in 0..cfg.meteor_shower_ticks {
160            ctx.deal_damage_ap(
161                HERO::ANNIHILATOR::SPELL::METEOR_SHOWER_DAMAGE,
162                cfg.meteor_shower_coef,
163                DamageFlags::empty(),
164            );
165        }
166    }
167
168    roll_untethered_rage(ctx, &cfg, fragments);
169    let was_spending = ctx.is_aura_active(AURA_VOIDFALL_SPENDING.raw());
170
171    spend_annihilator(ctx, &cfg);
172
173    if !was_spending {
174        roll_voidfall(ctx, annihilator_state(), annihilator_rules(&cfg), fragments);
175    }
176}
177
178/// Handles Metamorphosis's Annihilator riders.
179pub(crate) fn metamorphosis_hook(ctx: &mut HookCtx<'_>) {
180    let now_ms = ctx.now().as_millis();
181
182    {
183        let Some(runtime) = ctx.spec_runtime_mut::<AnnihilatorRuntime>() else {
184            return;
185        };
186
187        if runtime.last_meta_ms == now_ms {
188            return;
189        }
190
191        runtime.last_meta_ms = now_ms;
192    };
193    let cfg = annihilator_config(ctx);
194
195    if ctx.is_aura_active(AURA_UNTETHERED_RAGE.raw()) {
196        ctx.expire_aura(AURA_UNTETHERED_RAGE.raw());
197    }
198
199    if cfg.dark_matter {
200        ctx.apply_aura(AURA_DARK_MATTER.raw());
201    }
202
203    if cfg.voidfall && cfg.mass_accel_stacks > 0 {
204        gain_voidfall_building(
205            ctx,
206            annihilator_state(),
207            cfg.voidfall_max_stacks,
208            cfg.mass_accel_stacks,
209        );
210        ctx.reset_cooldown(SPELL_SPIRIT_BOMB.raw());
211    }
212}
213
214/// Keeps Fel Blood's per-hit multiplier neutral because it modifies attack power.
215#[must_use]
216pub(crate) const fn mastery(_ctx: &MasteryCtx<'_>) -> f64 {
217    1.0
218}