Skip to main content

wowlab_engine_content/hooks/shadow_priest/
hooks.rs

1use wowlab_engine_combat::DamageOps as _;
2use wowlab_engine_combat::ResourceOps as _;
3use wowlab_engine_combat::ProcOps as _;
4use wowlab_engine_combat::AuraOps as _;
5use wowlab_engine_combat::{DamageFlags, HookCtx, ImpactEvent, MasteryCtx};
6use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
7use wowlab_engine_rng::proc_chance;
8use wowlab_types::{
9    combat::DamageSchool,
10    constants::{HUNDRED, MS_PER_SECOND},
11    sim::SpellIdx,
12};
13
14use super::config::shadow_config;
15use crate::generated::specs::shadow_priest::{
16    AURA_ENTROPIC_RIFT, AURA_INSIDIOUS_IRE, AURA_SHADOW_WORD_MADNESS, AURA_SHADOW_WORD_PAIN,
17    AURA_SHADOWY_INSIGHT, AURA_SHATTERED_PSYCHE, AURA_VAMPIRIC_TOUCH, AURA_VOID_BLAST,
18    AURA_VOIDFORM, AURA_VOIDHEART, EFFECT, SPELL, TALENT,
19};
20
21/// The uniform roll is scaled by two to span the range(0, 2) accumulator.
22const SHADOWY_INSIGHT_INCREMENT_SCALE: f64 = 2.0;
23
24/// Server-side insanity amounts absent from DBC (spec aura 137033).
25const MIND_BLAST_INSANITY: f64 = 6.0;
26const VOID_BLAST_INSANITY: f64 = 8.0;
27/// Second tentacle's 6-insanity gain (1227280 e2 covers the first).
28const TENTACLE_SLAM_EXTRA_INSANITY: f64 = 6.0;
29const TENTACLE_SLAM_HITS: u8 = 2;
30/// Voidform counts as three Shadow Weaving `DoTs`.
31const VOIDFORM_WEAVING_DOTS: f64 = 3.0;
32const COLLAPSING_VOID_MAX_STACKS: u32 = 5;
33
34/// Applies Shadow Weaving mastery to Shadow damage.
35#[must_use]
36pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
37    if ctx.school != DamageSchool::Shadow {
38        return 1.0;
39    }
40
41    let dots = if ctx.is_aura_active(AURA_VOIDFORM) {
42        VOIDFORM_WEAVING_DOTS
43    } else {
44        f64::from(ctx.is_aura_active(AURA_SHADOW_WORD_PAIN))
45            + f64::from(ctx.is_aura_active(AURA_VAMPIRIC_TOUCH))
46            + f64::from(ctx.is_aura_active(AURA_SHADOW_WORD_MADNESS))
47    };
48
49    1.0 + dots * ctx.points(1) / HUNDRED
50}
51
52fn deal_effect_sp(ctx: &mut HookCtx<'_>, effect: (u32, u8)) {
53    let coef = ctx
54        .game_data()
55        .sp_coef(SpellIdx::from_raw(effect.0), effect.1);
56
57    ctx.deal_damage_sp(effect.0, coef, DamageFlags::empty());
58}
59
60fn refresh_insidious_ire(ctx: &mut HookCtx<'_>) {
61    if !shadow_config(ctx).insidious_ire {
62        return;
63    }
64
65    let Some(pain) = ctx.aura_remaining_ms(AURA_SHADOW_WORD_PAIN.raw()) else {
66        return;
67    };
68    let Some(touch) = ctx.aura_remaining_ms(AURA_VAMPIRIC_TOUCH.raw()) else {
69        return;
70    };
71    let Some(madness) = ctx.aura_remaining_ms(AURA_SHADOW_WORD_MADNESS.raw()) else {
72        return;
73    };
74    let remaining = pain.min(touch).min(madness);
75
76    if remaining >= ctx.aura_remaining_ms(AURA_INSIDIOUS_IRE.raw()).unwrap_or(0) {
77        ctx.apply_aura_with_duration(AURA_INSIDIOUS_IRE.raw(), remaining);
78    }
79}
80
81fn trigger_apparitions(ctx: &mut HookCtx<'_>) {
82    let cfg = shadow_config(ctx);
83
84    if !cfg.shadowy_apparitions || !ctx.is_aura_active(AURA_VAMPIRIC_TOUCH.raw()) {
85        return;
86    }
87
88    if cfg.void_apparition_chance > 0.0 && proc_chance(ctx.rng(), cfg.void_apparition_chance) {
89        deal_effect_sp(ctx, EFFECT::VOID_APPARITION_DAMAGE);
90        deal_effect_sp(ctx, EFFECT::VOID_BOLT_DAMAGE);
91    } else {
92        deal_effect_sp(ctx, EFFECT::SHADOWY_APPARITION_DAMAGE);
93    }
94
95    if cfg.auspicious_insanity > 0.0 {
96        ctx.gain_resource(cfg.auspicious_insanity);
97    }
98}
99
100/// Handles Mind Blast and its Void Blast replacement.
101pub(crate) fn mind_blast_hook(ctx: &mut HookCtx<'_>) {
102    let cfg = shadow_config(ctx);
103    // Consuming Shadowy Insight drops its +1 category max charge, removing the charge this cast spent.
104
105    ctx.consume_aura(AURA_SHADOWY_INSIGHT.raw());
106
107    if cfg.void_blast && ctx.is_aura_active(AURA_VOID_BLAST.raw()) {
108        deal_effect_sp(ctx, EFFECT::VOID_BLAST_DAMAGE);
109        ctx.gain_resource(VOID_BLAST_INSANITY);
110
111        if cfg.darkening_horizon && ctx.is_aura_active(AURA_ENTROPIC_RIFT.raw()) {
112            let max = wowlab_types::numeric::f64_to_u32_saturating_trunc(
113                ctx.game_data()
114                    .effect_base_points(EFFECT::DARKENING_HORIZON_MAX),
115            );
116
117            if ctx.proc_counter(TALENT::DARKENING_HORIZON) < max {
118                ctx.add_proc_counter(TALENT::DARKENING_HORIZON, 1);
119                let extension_ms = wowlab_types::numeric::f64_to_u32_saturating_trunc(
120                    ctx.game_data()
121                        .effect_base_points(EFFECT::DARKENING_HORIZON_EXT)
122                        * MS_PER_SECOND,
123                );
124
125                ctx.extend_aura(AURA_ENTROPIC_RIFT.raw(), extension_ms);
126                ctx.extend_aura(AURA_VOID_BLAST.raw(), extension_ms);
127                ctx.extend_aura(AURA_VOIDHEART.raw(), extension_ms);
128            }
129        }
130    } else {
131        let coef = ctx
132            .game_data()
133            .effect_sp_coefficient(EFFECT::MIND_BLAST_DAMAGE);
134
135        ctx.deal_damage_sp(SPELL::MIND_BLAST, coef, DamageFlags::empty());
136        ctx.gain_resource(MIND_BLAST_INSANITY);
137    }
138
139    if cfg.shattered_psyche {
140        // SimC expires every stack, not one (sc_priest.cpp:148-151).
141        ctx.consume_aura(AURA_SHATTERED_PSYCHE.raw());
142    }
143}
144
145pub(crate) fn shadow_word_pain_hook(ctx: &mut HookCtx<'_>) {
146    refresh_insidious_ire(ctx);
147}
148
149pub(crate) fn vampiric_touch_hook(ctx: &mut HookCtx<'_>) {
150    refresh_insidious_ire(ctx);
151}
152
153pub(crate) fn shadow_word_madness_hook(ctx: &mut HookCtx<'_>) {
154    trigger_apparitions(ctx);
155    let cfg = shadow_config(ctx);
156
157    if cfg.collapsing_void_pct > 0.0
158        && ctx.is_aura_active(AURA_ENTROPIC_RIFT.raw())
159        && ctx.proc_counter(TALENT::COLLAPSING_VOID) < COLLAPSING_VOID_MAX_STACKS
160    {
161        ctx.add_proc_counter(TALENT::COLLAPSING_VOID, 1);
162    }
163
164    refresh_insidious_ire(ctx);
165}
166
167/// Opens Entropic Rift and its talent windows.
168pub(crate) fn void_torrent_hook(ctx: &mut HookCtx<'_>) {
169    let cfg = shadow_config(ctx);
170
171    ctx.reset_proc_counter(TALENT::DARKENING_HORIZON);
172    ctx.reset_proc_counter(TALENT::COLLAPSING_VOID);
173    ctx.apply_aura(AURA_ENTROPIC_RIFT.raw());
174
175    if cfg.void_blast {
176        ctx.apply_aura(AURA_VOID_BLAST.raw());
177    }
178
179    if cfg.voidheart {
180        ctx.apply_aura(AURA_VOIDHEART.raw());
181    }
182}
183
184/// Entropic Rift expiry: Collapsing Void explosion (448403 e3), then window buffs drop.
185pub(crate) fn entropic_rift_expire_hook(ctx: &mut HookCtx<'_>) {
186    let cfg = shadow_config(ctx);
187
188    if cfg.collapsing_void_pct > 0.0 {
189        let stacks = f64::from(ctx.proc_counter(TALENT::COLLAPSING_VOID));
190        let coef = ctx
191            .game_data()
192            .effect_sp_coefficient(EFFECT::COLLAPSING_VOID_DAMAGE)
193            * (1.0 + stacks * cfg.collapsing_void_pct);
194
195        ctx.deal_damage_sp(EFFECT::COLLAPSING_VOID_DAMAGE.0, coef, DamageFlags::empty());
196    }
197
198    ctx.expire_aura(AURA_VOID_BLAST.raw());
199    ctx.expire_aura(AURA_VOIDHEART.raw());
200    ctx.reset_proc_counter(TALENT::DARKENING_HORIZON);
201    ctx.reset_proc_counter(TALENT::COLLAPSING_VOID);
202}
203
204/// Shadow Word: Death on-cast hook: 32379 e2 base hit, x(1 + e4/100) below the e3 execute threshold.
205pub(crate) fn shadow_word_death_hook(ctx: &mut HookCtx<'_>) {
206    let data = ctx.game_data();
207    let mut coef = data.effect_sp_coefficient(EFFECT::SHADOW_WORD_DEATH_DAMAGE);
208    let threshold = data.effect_base_points(EFFECT::SHADOW_WORD_DEATH_THRESHOLD) / HUNDRED;
209    let execute_bonus = data.effect_base_points(EFFECT::SHADOW_WORD_DEATH_EXECUTE) / HUNDRED;
210    let health_pct = ctx.target_health_fraction();
211
212    if health_pct < threshold {
213        coef *= 1.0 + execute_bonus;
214    }
215
216    ctx.deal_damage_sp(SPELL::SHADOW_WORD_DEATH, coef, DamageFlags::empty());
217}
218
219/// Mind Flay channel tick: insanity per tick (15407 e3, Surge of Insanity) and a Shattered Psyche stack.
220pub(crate) fn mind_flay_tick_hook(ctx: &mut HookCtx<'_>) {
221    let cfg = shadow_config(ctx);
222
223    if cfg.mind_flay_tick_insanity > 0.0 {
224        ctx.gain_resource(cfg.mind_flay_tick_insanity);
225    }
226
227    if cfg.shattered_psyche {
228        ctx.apply_aura(AURA_SHATTERED_PSYCHE.raw());
229    }
230}
231
232/// Tentacle Slam on-cast hook: two tentacle hits (1227621) plus the second 6-insanity gain.
233pub(crate) fn tentacle_slam_hook(ctx: &mut HookCtx<'_>) {
234    for _ in 0..TENTACLE_SLAM_HITS {
235        deal_effect_sp(ctx, EFFECT::TENTACLE_SLAM_DAMAGE);
236    }
237
238    ctx.gain_resource(TENTACLE_SLAM_EXTRA_INSANITY);
239    refresh_insidious_ire(ctx);
240}
241
242pub(super) const fn swp_filter(spell_id: u32) -> bool {
243    spell_id == SPELL::SHADOW_WORD_PAIN
244}
245
246/// Tormented Spirits: SWP damage events spawn apparition volleys.
247pub(super) fn tormented_spirits_fire(ctx: &mut HookCtx<'_>, _impact: ImpactEvent) {
248    trigger_apparitions(ctx);
249}
250
251/// Shadowy Insight accumulated-threshold roll per SWP tick; one SWP dot leaves the plain range(0, 2) scale.
252pub(super) fn shadowy_insight_fire(ctx: &mut HookCtx<'_>, _impact: ImpactEvent) {
253    let Some(idx) = shadow_config(ctx).shadowy_insight_threshold else {
254        return;
255    };
256
257    if ctx.roll_threshold(idx, SHADOWY_INSIGHT_INCREMENT_SCALE) {
258        ctx.apply_aura(AURA_SHADOWY_INSIGHT.raw());
259    }
260}