Skip to main content

wowlab_engine_content/hooks/shadow_priest/
hooks.rs

1use wowlab_engine_combat::DamageOps as _;
2use wowlab_engine_combat::CooldownOps as _;
3use wowlab_engine_combat::ResourceOps as _;
4use wowlab_engine_combat::ProcOps as _;
5use wowlab_engine_combat::AuraOps as _;
6use wowlab_engine_combat::{DamageFlags, HookCtx, ImpactEvent, MasteryCtx};
7use wowlab_engine_rng::proc_chance;
8use wowlab_types::{
9    combat::DamageSchool,
10    constants::{HUNDRED, MS_PER_SECOND},
11};
12
13use super::config::shadow_config;
14use crate::generated::specs::shadow_priest::{
15    AURA_ANCIENT_MADNESS, AURA_ANCIENT_MADNESS_EXTENSION, AURA_ENTROPIC_RIFT,
16    AURA_INSIDIOUS_IRE, AURA_SHADOW_WORD_MADNESS, AURA_SHADOW_WORD_PAIN, AURA_SHADOWY_INSIGHT,
17    AURA_SHATTERED_PSYCHE, AURA_VAMPIRIC_TOUCH, AURA_VOID_BLAST,
18    AURA_VOIDFORM, AURA_VOIDHEART, AURA_VOID_VOLLEY_CHARGES, AURA_VOID_VOLLEY_SET_BONUS,
19    SPELL, SPELL_TENTACLE_SLAM,
20};
21
22/// The uniform roll is scaled by two to span the range(0, 2) accumulator.
23const SHADOWY_INSIGHT_INCREMENT_SCALE: f64 = 2.0;
24
25/// Server-side insanity amounts absent from DBC (spec aura 137033).
26const MIND_BLAST_INSANITY: f64 = 6.0;
27const VOID_BLAST_INSANITY: f64 = 8.0;
28/// Second tentacle's 6-insanity gain (1227280 e2 covers the first).
29const TENTACLE_SLAM_EXTRA_INSANITY: f64 = 6.0;
30const TENTACLE_SLAM_HITS: u8 = 2;
31/// Voidform counts as three Shadow Weaving `DoTs`.
32const VOIDFORM_WEAVING_DOTS: f64 = 3.0;
33const COLLAPSING_VOID_MAX_STACKS: u32 = 5;
34
35/// Applies Shadow Weaving mastery to Shadow damage.
36#[must_use]
37pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
38    if ctx.school != DamageSchool::Shadow {
39        return 1.0;
40    }
41
42    let dots = if ctx.is_aura_active(AURA_VOIDFORM) {
43        VOIDFORM_WEAVING_DOTS
44    } else {
45        f64::from(ctx.is_aura_active(AURA_SHADOW_WORD_PAIN))
46            + f64::from(ctx.is_aura_active(AURA_VAMPIRIC_TOUCH))
47            + f64::from(ctx.is_aura_active(AURA_SHADOW_WORD_MADNESS))
48    };
49
50    1.0 + dots * ctx.points(1) / HUNDRED
51}
52
53fn deal_effect_sp(ctx: &mut HookCtx<'_>, effect: wowlab_engine_combat::DamageEffectId) {
54    let coef = ctx.effect_sp_coefficient(effect);
55
56    ctx.deal_damage_sp(effect.action(), coef, DamageFlags::empty());
57}
58
59fn refresh_insidious_ire(ctx: &mut HookCtx<'_>) {
60    if !shadow_config(ctx).insidious_ire {
61        return;
62    }
63
64    let Some(pain) = ctx.aura_remaining_ms(AURA_SHADOW_WORD_PAIN) else {
65        return;
66    };
67    let Some(touch) = ctx.aura_remaining_ms(AURA_VAMPIRIC_TOUCH) else {
68        return;
69    };
70    let Some(madness) = ctx.aura_remaining_ms(AURA_SHADOW_WORD_MADNESS) else {
71        return;
72    };
73    let remaining = pain.min(touch).min(madness);
74
75    if remaining >= ctx.aura_remaining_ms(AURA_INSIDIOUS_IRE).unwrap_or(0) {
76        ctx.apply_aura_with_duration(AURA_INSIDIOUS_IRE, remaining);
77    }
78}
79
80fn trigger_apparitions(ctx: &mut HookCtx<'_>) {
81    let cfg = shadow_config(ctx);
82
83    if !cfg.shadowy_apparitions || !ctx.is_aura_active(AURA_VAMPIRIC_TOUCH) {
84        return;
85    }
86
87    if cfg.void_apparition_chance > 0.0 && proc_chance(ctx.rng(), cfg.void_apparition_chance) {
88        deal_effect_sp(ctx, crate::generated::specs::shadow_priest::DAMAGE_EFFECT::VOID_APPARITION_DAMAGE);
89        deal_effect_sp(ctx, crate::generated::specs::shadow_priest::DAMAGE_EFFECT::VOID_BOLT_DAMAGE);
90    } else {
91        deal_effect_sp(ctx, crate::generated::specs::shadow_priest::DAMAGE_EFFECT::SHADOWY_APPARITION_DAMAGE);
92    }
93
94    if cfg.shadeburst {
95        let effect = crate::generated::specs::shadow_priest::DAMAGE_EFFECT::SHADEBURST_DAMAGE;
96
97        ctx.deal_effect_damage_sp_with_geometry(effect, effect, 1.0, DamageFlags::empty());
98    }
99
100    if cfg.auspicious_insanity > 0.0 {
101        ctx.gain_resource(cfg.auspicious_insanity);
102    }
103}
104
105/// Handles Mind Blast and its Void Blast replacement.
106pub(crate) fn mind_blast_hook(ctx: &mut HookCtx<'_>) {
107    let cfg = shadow_config(ctx);
108    // Consuming Shadowy Insight drops its +1 category max charge, removing the charge this cast spent.
109
110    ctx.consume_aura(AURA_SHADOWY_INSIGHT);
111
112    if cfg.void_blast && ctx.is_aura_active(AURA_VOID_BLAST) {
113        deal_effect_sp(ctx, crate::generated::specs::shadow_priest::DAMAGE_EFFECT::VOID_BLAST_DAMAGE);
114        ctx.gain_resource(VOID_BLAST_INSANITY);
115
116        if cfg.darkening_horizon && ctx.is_aura_active(AURA_ENTROPIC_RIFT) {
117            let max = wowlab_types::numeric::f64_to_u32_saturating_trunc(
118                ctx.effect_base_points(crate::generated::specs::shadow_priest::DAMAGE_EFFECT::DARKENING_HORIZON_MAX),
119            );
120
121            if ctx.proc_counter(wowlab_engine_combat::generated_identities::proc_counter::DARKENING_HORIZON) < max {
122                ctx.add_proc_counter(wowlab_engine_combat::generated_identities::proc_counter::DARKENING_HORIZON, 1);
123                let extension_ms = wowlab_types::numeric::f64_to_u32_saturating_trunc(
124                    ctx.effect_base_points(crate::generated::specs::shadow_priest::DAMAGE_EFFECT::DARKENING_HORIZON_EXT)
125                        * MS_PER_SECOND,
126                );
127
128                ctx.extend_aura(AURA_ENTROPIC_RIFT, extension_ms);
129                ctx.extend_aura(AURA_VOID_BLAST, extension_ms);
130                ctx.extend_aura(AURA_VOIDHEART, extension_ms);
131            }
132        }
133    } else {
134        let coef = ctx.effect_sp_coefficient(crate::generated::specs::shadow_priest::DAMAGE_EFFECT::MIND_BLAST_DAMAGE);
135
136        ctx.deal_damage_sp(crate::generated::specs::shadow_priest::DAMAGE_SPELL::MIND_BLAST, coef, DamageFlags::empty());
137        ctx.gain_resource(MIND_BLAST_INSANITY);
138    }
139
140    if cfg.shattered_psyche {
141        // Expire every stack, not just one.
142        ctx.consume_aura(AURA_SHATTERED_PSYCHE);
143    }
144}
145
146pub(crate) fn shadow_word_pain_hook(ctx: &mut HookCtx<'_>) {
147    refresh_insidious_ire(ctx);
148}
149
150pub(crate) fn vampiric_touch_hook(ctx: &mut HookCtx<'_>) {
151    refresh_insidious_ire(ctx);
152}
153
154pub(crate) fn shadow_word_madness_hook(ctx: &mut HookCtx<'_>) {
155    trigger_apparitions(ctx);
156    let cfg = shadow_config(ctx);
157
158    if cfg.ancient_madness
159        && ctx.is_aura_active(AURA_VOIDFORM)
160        && u32::try_from(ctx.aura_stacks(AURA_ANCIENT_MADNESS_EXTENSION).max(0))
161            .unwrap_or(u32::MAX)
162            < cfg.ancient_madness_extension_max
163    {
164        ctx.extend_aura(AURA_VOIDFORM, cfg.ancient_madness_extension_ms);
165        ctx.apply_aura(AURA_ANCIENT_MADNESS_EXTENSION);
166    }
167
168    if cfg.collapsing_void_pct > 0.0
169        && ctx.is_aura_active(AURA_ENTROPIC_RIFT)
170        && ctx.proc_counter(wowlab_engine_combat::generated_identities::proc_counter::COLLAPSING_VOID) < COLLAPSING_VOID_MAX_STACKS
171    {
172        ctx.add_proc_counter(wowlab_engine_combat::generated_identities::proc_counter::COLLAPSING_VOID, 1);
173    }
174
175    refresh_insidious_ire(ctx);
176}
177
178/// Opens Entropic Rift and its talent windows.
179pub(crate) fn void_torrent_hook(ctx: &mut HookCtx<'_>) {
180    let cfg = shadow_config(ctx);
181
182    ctx.reset_proc_counter(wowlab_engine_combat::generated_identities::proc_counter::DARKENING_HORIZON);
183    ctx.reset_proc_counter(wowlab_engine_combat::generated_identities::proc_counter::COLLAPSING_VOID);
184    ctx.apply_aura(AURA_ENTROPIC_RIFT);
185
186    if cfg.void_blast {
187        ctx.apply_aura(AURA_VOID_BLAST);
188    }
189
190    if cfg.voidheart {
191        ctx.apply_aura(AURA_VOIDHEART);
192    }
193}
194
195/// Entropic Rift expiry: Collapsing Void explosion (448403 e3), then window buffs drop.
196pub(crate) fn entropic_rift_expire_hook(ctx: &mut HookCtx<'_>) {
197    let cfg = shadow_config(ctx);
198
199    if cfg.collapsing_void_pct > 0.0 {
200        let stacks = f64::from(ctx.proc_counter(wowlab_engine_combat::generated_identities::proc_counter::COLLAPSING_VOID));
201        let coef = ctx.effect_sp_coefficient(crate::generated::specs::shadow_priest::DAMAGE_EFFECT::COLLAPSING_VOID_DAMAGE)
202            * (1.0 + stacks * cfg.collapsing_void_pct);
203
204        ctx.deal_damage_sp(crate::generated::specs::shadow_priest::DAMAGE_EFFECT::COLLAPSING_VOID_DAMAGE.action(), coef, DamageFlags::empty());
205    }
206
207    ctx.expire_aura(AURA_VOID_BLAST);
208    ctx.expire_aura(AURA_VOIDHEART);
209    ctx.reset_proc_counter(wowlab_engine_combat::generated_identities::proc_counter::DARKENING_HORIZON);
210    ctx.reset_proc_counter(wowlab_engine_combat::generated_identities::proc_counter::COLLAPSING_VOID);
211}
212
213/// Shadow Word: Death on-cast hook: 32379 e2 base hit, x(1 + e4/100) below the e3 execute threshold.
214pub(crate) fn shadow_word_death_hook(ctx: &mut HookCtx<'_>) {
215    let mut coef = ctx.effect_sp_coefficient(
216        crate::generated::specs::shadow_priest::DAMAGE_EFFECT::SHADOW_WORD_DEATH_DAMAGE,
217    );
218    let threshold = ctx.effect_percent(
219        crate::generated::specs::shadow_priest::DAMAGE_EFFECT::SHADOW_WORD_DEATH_THRESHOLD,
220    );
221    let execute_bonus = ctx.effect_percent(
222        crate::generated::specs::shadow_priest::DAMAGE_EFFECT::SHADOW_WORD_DEATH_EXECUTE,
223    );
224    let health_pct = ctx.target_health_fraction();
225
226    if health_pct < threshold {
227        coef *= 1.0 + execute_bonus;
228    }
229
230    ctx.deal_damage_sp(crate::generated::specs::shadow_priest::DAMAGE_SPELL::SHADOW_WORD_DEATH, coef, DamageFlags::empty());
231}
232
233/// Mind Flay channel tick: insanity per tick (15407 e3, Surge of Insanity) and a Shattered Psyche stack.
234pub(crate) fn mind_flay_tick_hook(ctx: &mut HookCtx<'_>) {
235    let cfg = shadow_config(ctx);
236
237    if cfg.mind_flay_tick_insanity > 0.0 {
238        ctx.gain_resource(cfg.mind_flay_tick_insanity);
239    }
240
241    if cfg.shattered_psyche {
242        ctx.apply_aura(AURA_SHATTERED_PSYCHE);
243    }
244}
245
246/// Tentacle Slam on-cast hook: two tentacle hits (1227621) plus the second 6-insanity gain.
247pub(crate) fn tentacle_slam_hook(ctx: &mut HookCtx<'_>) {
248    let cfg = shadow_config(ctx);
249
250    for _ in 0..TENTACLE_SLAM_HITS {
251        let effect = crate::generated::specs::shadow_priest::DAMAGE_EFFECT::TENTACLE_SLAM_DAMAGE;
252        let coef = ctx.effect_sp_coefficient(effect) * cfg.mid2_tentacle_damage_mult;
253
254        ctx.deal_damage_sp(effect.action(), coef, DamageFlags::empty());
255    }
256
257    if cfg.mid2_tentacle_cooldown_reduction_ms > 0 {
258        ctx.reduce_cooldown(SPELL_TENTACLE_SLAM, cfg.mid2_tentacle_cooldown_reduction_ms);
259    }
260
261    if cfg.mid2_volley_chance > 0.0 && proc_chance(ctx.rng(), cfg.mid2_volley_chance) {
262        let charges_before = ctx.aura_stacks(AURA_VOID_VOLLEY_CHARGES);
263
264        ctx.apply_aura(AURA_VOID_VOLLEY_CHARGES);
265
266        if ctx.aura_stacks(AURA_VOID_VOLLEY_CHARGES) > charges_before {
267            ctx.apply_aura(AURA_VOID_VOLLEY_SET_BONUS);
268        }
269    }
270
271    ctx.gain_resource(TENTACLE_SLAM_EXTRA_INSANITY);
272    refresh_insidious_ire(ctx);
273}
274
275fn deal_void_volley(ctx: &mut HookCtx<'_>, effectiveness: f64) {
276    let cfg = shadow_config(ctx);
277    let effect = crate::generated::specs::shadow_priest::DAMAGE_EFFECT::VOID_VOLLEY_BOLT_DAMAGE;
278    let coef = ctx.effect_sp_coefficient(effect) * effectiveness;
279
280    for _ in 0..cfg.void_volley_bolts {
281        ctx.deal_damage_sp(effect.action(), coef, DamageFlags::empty());
282    }
283
284    let primary = ctx.target();
285
286    ctx.with_resolved_spell_effect_targets(
287        crate::generated::specs::shadow_priest::DAMAGE_EFFECT::VOID_VOLLEY_TARGETS,
288        effect.action(),
289        None,
290        |ctx, targets| {
291            for target in targets
292                .iter()
293                .copied()
294                .filter(|target| Some(*target) != primary)
295            {
296                for _ in 0..cfg.void_volley_secondary_bolts {
297                    ctx.deal_damage_sp_to_target(
298                        target,
299                        effect.action(),
300                        coef,
301                        DamageFlags::empty(),
302                    );
303                }
304            }
305        },
306    );
307    ctx.gain_resource(cfg.void_volley_insanity);
308}
309
310/// Voidform fires one immediate volley and banks its data-owned number of casts.
311pub(crate) fn voidform_hook(ctx: &mut HookCtx<'_>) {
312    deal_void_volley(ctx, 1.0);
313
314    for _ in 0..shadow_config(ctx).voidform_volley_charges {
315        ctx.apply_aura(AURA_VOID_VOLLEY_CHARGES);
316    }
317}
318
319/// Consumes a banked Void Volley, preserving the four-piece's per-charge effectiveness marker.
320pub(crate) fn void_volley_hook(ctx: &mut HookCtx<'_>) {
321    let set_bonus_cast = ctx.consume_aura_stack(AURA_VOID_VOLLEY_SET_BONUS);
322    let effectiveness = if set_bonus_cast {
323        shadow_config(ctx).mid2_volley_effectiveness
324    } else {
325        1.0
326    };
327
328    ctx.consume_aura_stack(AURA_VOID_VOLLEY_CHARGES);
329    deal_void_volley(ctx, effectiveness);
330}
331
332/// Converts Voidform extension progress into Ancient Madness's decaying haste curve.
333pub(crate) fn voidform_expire_hook(ctx: &mut HookCtx<'_>) {
334    let cfg = shadow_config(ctx);
335    let extensions = u32::try_from(ctx.aura_stacks(AURA_ANCIENT_MADNESS_EXTENSION).max(0))
336        .unwrap_or(u32::MAX);
337
338    ctx.expire_aura(AURA_ANCIENT_MADNESS_EXTENSION);
339
340    for _ in 0..extensions.saturating_mul(cfg.ancient_madness_haste_stacks_per_extension) {
341        ctx.apply_aura(AURA_ANCIENT_MADNESS);
342    }
343
344    if cfg.crushing_void {
345        ctx.apply_aura(AURA_VOID_VOLLEY_CHARGES);
346    }
347}
348
349pub(super) const fn swp_filter(spell_id: u32) -> bool {
350    spell_id == SPELL::SHADOW_WORD_PAIN
351}
352
353/// Tormented Spirits: SWP damage events spawn apparition volleys.
354pub(super) fn tormented_spirits_fire(ctx: &mut HookCtx<'_>, _impact: ImpactEvent) {
355    trigger_apparitions(ctx);
356}
357
358/// Shadowy Insight accumulated-threshold roll per SWP tick; one SWP dot leaves the plain range(0, 2) scale.
359pub(super) fn shadowy_insight_fire(ctx: &mut HookCtx<'_>, _impact: ImpactEvent) {
360    let Some(idx) = shadow_config(ctx).shadowy_insight_threshold else {
361        return;
362    };
363
364    if ctx.roll_threshold(idx, SHADOWY_INSIGHT_INCREMENT_SCALE) {
365        ctx.apply_aura(AURA_SHADOWY_INSIGHT);
366    }
367}