Skip to main content

wowlab_engine_content/hooks/retribution_paladin/
hooks.rs

1//! Retribution per-spell cast hooks, the swing hook, and mastery.
2
3use wowlab_engine_combat::DamageOps as _;
4use wowlab_engine_combat::CooldownOps as _;
5use wowlab_engine_combat::ResourceOps as _;
6use wowlab_engine_combat::ProcOps as _;
7use wowlab_engine_combat::SchedulingOps as _;
8use wowlab_engine_combat::AuraOps as _;
9use wowlab_engine_combat::{DamageFlags, HookCtx, MasteryCtx, SwingEvent, SwingHand};
10use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
11use wowlab_engine_rng::proc_chance;
12use wowlab_types::{constants::HUNDRED};
13
14use super::{bugs::BUG_HAMMER_OF_LIGHT_CRUSADE, config::retribution_config, templar};
15use crate::generated::specs::retribution_paladin::{
16    AURA_ART_OF_WAR, AURA_AVENGING_WRATH, AURA_CONSECRATED_BLADE_LOCKOUT, AURA_DIVINE_PURPOSE,
17    AURA_EMPYREAN_LEGACY, AURA_EMPYREAN_POWER, AURA_EXECUTION_SENTENCE_TRACKER, AURA_EXPURGATION,
18    AURA_HAMMER_OF_WRATH, AURA_JUDGMENT_DEBUFF, AURA_RIGHTEOUS_CAUSE, AURA_SANCTIFICATION,
19    AURA_SANCTIFY, EFFECT, REPORTED_SPELL, SPELL, SPELL_BLADE_OF_JUSTICE, TALENT,
20};
21
22const NORMAL_SPENDER_HOLY_POWER: u8 = 3;
23const FREE_SPENDER_CRUSADE_STACKS: i32 = 3;
24const HAMMER_OF_LIGHT_CRUSADE_STACKS: i32 = 5;
25const BUGGED_HAMMER_OF_LIGHT_CRUSADE_STACKS: i32 = 10;
26const GREATER_JUDGMENT_DEBUFF_STACKS: i32 = 2;
27
28const CRUSADING_STRIKE_HIGHER_CALLING_MS: u32 = 500;
29
30pub(crate) const fn blade_of_vengeance_trigger_disabled(_ctx: &HookCtx<'_>) -> bool {
31    false
32}
33
34/// Consumes the live Divine Purpose charge, then rolls a fresh one.
35///
36/// `SimC` expires the buff before triggering it (`holy_power_consumer_t::execute`).
37pub(crate) fn divine_purpose_cycle(ctx: &mut HookCtx<'_>) {
38    ctx.consume_aura(AURA_DIVINE_PURPOSE.raw());
39
40    let chance = retribution_config(ctx).divine_purpose_chance;
41
42    if chance > 0.0 && proc_chance(ctx.rng(), chance) {
43        ctx.apply_aura(AURA_DIVINE_PURPOSE.raw());
44    }
45}
46
47/// Righteous Cause rolls once per Holy Power in a normal three-point spender.
48pub(crate) fn righteous_cause_roll(ctx: &mut HookCtx<'_>) {
49    let chance = retribution_config(ctx).righteous_cause_chance;
50
51    if chance <= 0.0 || !ctx.proc_category_ready(TALENT::RIGHTEOUS_CAUSE) {
52        return;
53    }
54
55    for _ in 0..NORMAL_SPENDER_HOLY_POWER {
56        if proc_chance(ctx.rng(), chance) {
57            ctx.apply_aura(AURA_RIGHTEOUS_CAUSE.raw());
58            ctx.reset_cooldown(SPELL_BLADE_OF_JUSTICE.raw());
59            ctx.start_proc_category_cooldown(TALENT::RIGHTEOUS_CAUSE);
60            break;
61        }
62    }
63}
64
65fn add_crusade_stacks(ctx: &mut HookCtx<'_>, count: i32) {
66    if !retribution_config(ctx).crusade || !ctx.is_aura_active(AURA_AVENGING_WRATH.raw()) {
67        return;
68    }
69
70    for _ in 0..count.max(0) {
71        ctx.add_aura_stack(AURA_AVENGING_WRATH.raw());
72    }
73}
74
75pub(crate) fn crusade_spender_stacks(ctx: &mut HookCtx<'_>) {
76    let spent =
77        wowlab_types::numeric::f64_to_i32_saturating_trunc(ctx.last_primary_spent().floor());
78
79    add_crusade_stacks(
80        ctx,
81        if spent > 0 {
82            spent
83        } else {
84            FREE_SPENDER_CRUSADE_STACKS
85        },
86    );
87}
88
89pub(crate) fn crusade_hammer_of_light_stacks(ctx: &mut HookCtx<'_>) {
90    let count = if ctx.state().bug_enabled(&BUG_HAMMER_OF_LIGHT_CRUSADE)
91        && ctx.aura_stacks(AURA_AVENGING_WRATH.raw()) < HAMMER_OF_LIGHT_CRUSADE_STACKS
92    {
93        BUGGED_HAMMER_OF_LIGHT_CRUSADE_STACKS
94    } else {
95        HAMMER_OF_LIGHT_CRUSADE_STACKS
96    };
97
98    add_crusade_stacks(ctx, count);
99}
100
101/// Handles Crusading Strikes and Art of War on auto attacks.
102pub(crate) fn swing_hook(ctx: &mut HookCtx<'_>, event: SwingEvent) {
103    let hand = event.hand;
104    let is_crit = event.is_crit;
105
106    if hand == SwingHand::OffHand {
107        return;
108    }
109
110    let cfg = retribution_config(ctx);
111
112    if cfg.crusading_strikes {
113        let data = ctx.game_data();
114        let hp = data.effect_base_points(EFFECT::CRUSADING_STRIKES_HP);
115
116        ctx.gain_resource(hp);
117
118        if cfg.empyrean_power_chance > 0.0 && proc_chance(ctx.rng(), cfg.empyrean_power_chance) {
119            ctx.apply_aura(AURA_EMPYREAN_POWER.raw());
120        }
121
122        if cfg.higher_calling_ms > 0 {
123            templar::higher_calling_extend(ctx, CRUSADING_STRIKE_HIGHER_CALLING_MS);
124        }
125    }
126
127    if cfg.art_of_war_chance <= 0.0 || !ctx.proc_category_ready(TALENT::ART_OF_WAR) {
128        return;
129    }
130
131    let mut chance = cfg.art_of_war_chance;
132
133    if is_crit {
134        chance *= 1.0 + cfg.art_of_war_crit_bonus;
135    }
136
137    if proc_chance(ctx.rng(), chance) {
138        ctx.apply_aura(AURA_ART_OF_WAR.raw());
139        ctx.reset_cooldown(SPELL_BLADE_OF_JUSTICE.raw());
140        ctx.start_proc_category_cooldown(TALENT::ART_OF_WAR);
141    }
142}
143
144fn judgment_family_execute(ctx: &mut HookCtx<'_>) {
145    let cfg = retribution_config(ctx);
146
147    if cfg.sanctification {
148        ctx.apply_aura(AURA_SANCTIFICATION.raw());
149    }
150
151    templar::higher_calling_extend(ctx, cfg.higher_calling_ms);
152}
153
154const fn judgment_debuff_stacks(greater_judgment: bool) -> i32 {
155    if greater_judgment {
156        GREATER_JUDGMENT_DEBUFF_STACKS
157    } else {
158        1
159    }
160}
161
162pub(super) fn apply_judgment_debuff(ctx: &mut HookCtx<'_>) {
163    let stacks = judgment_debuff_stacks(retribution_config(ctx).greater_judgment);
164
165    ctx.apply_aura_n(AURA_JUDGMENT_DEBUFF.raw(), stacks);
166}
167
168/// Judgment on-cast hook: target debuff + judgment-family execute tail.
169pub(crate) fn judgment_hook(ctx: &mut HookCtx<'_>) {
170    apply_judgment_debuff(ctx);
171    judgment_family_execute(ctx);
172}
173
174fn vengeful_wrath_multiplier(max_bonus: f64, target_health_fraction: f64) -> f64 {
175    const FULL_EFFECT_HEALTH_FRACTION: f64 = 0.2;
176
177    let scaled = 1.0
178        - ((target_health_fraction - FULL_EFFECT_HEALTH_FRACTION).max(0.0)
179            / (1.0 - FULL_EFFECT_HEALTH_FRACTION))
180            .clamp(0.0, 1.0);
181
182    1.0 + max_bonus * scaled
183}
184
185/// Applies Hammer of Wrath's judgment-family riders.
186pub(crate) fn hammer_of_wrath_hook(ctx: &mut HookCtx<'_>) {
187    let coef = ctx
188        .game_data()
189        .effect_ap_coefficient(EFFECT::HAMMER_OF_WRATH_DAMAGE);
190    let health_mult = vengeful_wrath_multiplier(
191        retribution_config(ctx).vengeful_wrath_max_bonus,
192        ctx.target_health_fraction(),
193    );
194
195    ctx.deal_damage_ap(
196        SPELL::HAMMER_OF_WRATH,
197        coef * health_mult,
198        DamageFlags::empty(),
199    );
200    apply_judgment_debuff(ctx);
201    judgment_family_execute(ctx);
202}
203
204const LIGHT_WITHIN_DELAY_MS: u32 = 350;
205
206fn light_within_fire_hook(ctx: &mut HookCtx<'_>) {
207    let coef = ctx.game_data().effect_ap_coefficient(EFFECT::LIGHT_WITHIN);
208    let mult = retribution_config(ctx).light_within_wave_mult.max(1.0);
209
210    ctx.deal_damage_ap(EFFECT::LIGHT_WITHIN.0, coef * mult, DamageFlags::empty());
211}
212
213/// Handles Blade of Justice's reset buffs and Light Within nuke.
214pub(crate) fn blade_of_justice_hook(ctx: &mut HookCtx<'_>) {
215    ctx.apply_aura(AURA_EXPURGATION.raw());
216    let cfg = retribution_config(ctx);
217
218    if cfg.consecrated_blade
219        && !ctx.is_aura_active(AURA_CONSECRATED_BLADE_LOCKOUT.raw())
220        && ctx.schedule_ground_effect_from_data(
221            EFFECT::CONSECRATION_TIMING.0,
222            EFFECT::CONSECRATION_TIMING.1,
223            EFFECT::CONSECRATION_GEOMETRY,
224            EFFECT::CONSECRATION_DAMAGE,
225            DamageFlags::PERIODIC,
226        )
227    {
228        ctx.apply_aura(AURA_CONSECRATED_BLADE_LOCKOUT.raw());
229    }
230
231    let art_of_war = ctx.consume_aura(AURA_ART_OF_WAR.raw());
232    let righteous_cause = ctx.consume_aura(AURA_RIGHTEOUS_CAUSE.raw());
233    let buff_up = art_of_war || righteous_cause;
234
235    if buff_up && cfg.light_within {
236        ctx.schedule_hook(LIGHT_WITHIN_DELAY_MS, light_within_fire_hook);
237    }
238}
239
240/// Applies the MID1 4pc rolling Expurgation; the shared passive pass supplies the 2pc +20% for spell 383346.
241fn tier_expurgation(ctx: &mut HookCtx<'_>, effectiveness: f64) {
242    if effectiveness <= 0.0 {
243        return;
244    }
245
246    ctx.apply_aura_with_rolling_multiplier(AURA_EXPURGATION.raw(), effectiveness);
247}
248
249fn trigger_empyrean_legacy_divine_storm(
250    ctx: &mut HookCtx<'_>,
251    effectiveness: f64,
252    tier_effectiveness: f64,
253) -> bool {
254    if !ctx.consume_aura(AURA_EMPYREAN_LEGACY.raw()) {
255        return false;
256    }
257
258    let coef = ctx
259        .game_data()
260        .effect_ap_coefficient(EFFECT::DIVINE_STORM_DAMAGE);
261
262    ctx.deal_damage_ap(
263        SPELL::DIVINE_STORM,
264        coef * effectiveness,
265        DamageFlags::empty(),
266    );
267    ctx.consume_aura(AURA_EMPYREAN_POWER.raw());
268    ctx.consume_aura(AURA_DIVINE_PURPOSE.raw());
269    tier_expurgation(ctx, tier_effectiveness);
270
271    true
272}
273
274pub(crate) fn templars_verdict_hook(ctx: &mut HookCtx<'_>) {
275    ctx.consume_aura_stack(AURA_JUDGMENT_DEBUFF.raw());
276
277    templar::holy_power_spender(ctx);
278    let cfg = retribution_config(ctx);
279
280    tier_expurgation(ctx, cfg.mid1_expurgation_final_verdict);
281
282    if cfg.empyrean_legacy {
283        trigger_empyrean_legacy_divine_storm(
284            ctx,
285            cfg.empyrean_legacy_effectiveness,
286            cfg.mid1_expurgation_divine_storm,
287        );
288    }
289}
290
291/// Handles Divine Storm's spender riders.
292pub(crate) fn divine_storm_hook(ctx: &mut HookCtx<'_>) {
293    ctx.consume_aura_stack(AURA_JUDGMENT_DEBUFF.raw());
294    ctx.consume_aura(AURA_EMPYREAN_POWER.raw());
295
296    if retribution_config(ctx).sanctify {
297        ctx.apply_aura(AURA_SANCTIFY.raw());
298    }
299
300    templar::holy_power_spender(ctx);
301    tier_expurgation(ctx, retribution_config(ctx).mid1_expurgation_divine_storm);
302}
303
304pub(crate) fn execution_sentence_hook(ctx: &mut HookCtx<'_>) {
305    templar::holy_power_spender(ctx);
306    ctx.consume_aura_stack(AURA_JUDGMENT_DEBUFF.raw());
307}
308
309pub(crate) fn execution_sentence_expire_hook(ctx: &mut HookCtx<'_>) {
310    let pct = ctx
311        .game_data()
312        .effect_base_points(EFFECT::EXECUTION_SENTENCE_ACCUM_PCT)
313        / HUNDRED;
314    let accumulated = ctx.take_accumulated_damage(AURA_EXECUTION_SENTENCE_TRACKER.raw());
315
316    ctx.deal_damage_flat(REPORTED_SPELL::EXECUTION_SENTENCE, accumulated * pct);
317    ctx.expire_aura(AURA_EXECUTION_SENTENCE_TRACKER.raw());
318}
319
320fn judgment_echo(ctx: &mut HookCtx<'_>, mult: f64, use_how: bool) {
321    let data = ctx.game_data();
322
323    if use_how {
324        let coef = data.effect_ap_coefficient(EFFECT::DIVINE_TOLL_HOW)
325            * mult
326            * vengeful_wrath_multiplier(
327                retribution_config(ctx).vengeful_wrath_max_bonus,
328                ctx.target_health_fraction(),
329            );
330
331        ctx.deal_damage_ap(EFFECT::DIVINE_TOLL_HOW.0, coef, DamageFlags::empty());
332    } else {
333        let coef = data.effect_sp_coefficient(EFFECT::DIVINE_TOLL_JUDGMENT) * mult;
334
335        ctx.deal_damage_sp(EFFECT::DIVINE_TOLL_JUDGMENT.0, coef, DamageFlags::empty());
336    }
337
338    ctx.gain_resource(1.0);
339    apply_judgment_debuff(ctx);
340    judgment_family_execute(ctx);
341}
342
343const DIVINE_EXACTION_ECHO_DELAY_MS: u32 = 300;
344
345fn divine_exaction_judgment_fire_hook(ctx: &mut HookCtx<'_>) {
346    let mult = retribution_config(ctx).divine_exaction_mult;
347
348    judgment_echo(ctx, mult, false);
349}
350
351fn divine_exaction_how_fire_hook(ctx: &mut HookCtx<'_>) {
352    let mult = retribution_config(ctx).divine_exaction_mult;
353
354    judgment_echo(ctx, mult, true);
355}
356
357/// Handles Divine Toll's immediate and delayed echoes.
358pub(crate) fn divine_toll_hook(ctx: &mut HookCtx<'_>) {
359    let cfg = retribution_config(ctx);
360    let how_up = ctx.is_aura_active(AURA_HAMMER_OF_WRATH.raw());
361
362    judgment_echo(ctx, cfg.divine_toll_echo_mult, how_up);
363    let fire = if how_up {
364        divine_exaction_how_fire_hook
365    } else {
366        divine_exaction_judgment_fire_hook
367    };
368
369    for i in 0..cfg.divine_exaction_count {
370        ctx.schedule_hook(DIVINE_EXACTION_ECHO_DELAY_MS * (i + 1), fire);
371    }
372
373    templar::start_divine_hammer(ctx);
374}
375
376/// Opens Avenging Wrath's Hammer of Wrath window.
377pub(crate) fn avenging_wrath_hook(ctx: &mut HookCtx<'_>) {
378    let cfg = retribution_config(ctx);
379
380    if cfg.hammer_of_wrath {
381        ctx.apply_aura(AURA_HAMMER_OF_WRATH.raw());
382    }
383
384    if cfg.empyrean_legacy {
385        ctx.apply_aura(AURA_EMPYREAN_LEGACY.raw());
386    }
387}
388
389/// Closes Avenging Wrath's Hammer of Wrath window.
390pub(crate) fn avenging_wrath_expire_hook(ctx: &mut HookCtx<'_>) {
391    ctx.expire_aura(AURA_HAMMER_OF_WRATH.raw());
392}
393
394/// Applies Highlord's Judgment mastery (spell 267316).
395#[must_use]
396pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
397    if ctx.affects_spell(EFFECT::HIGHLORDS_MASTERY_DAMAGE.1) {
398        ctx.bonus(EFFECT::HIGHLORDS_MASTERY_DAMAGE.1)
399    } else {
400        1.0
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use googletest::prelude::*;
407    use wowlab_engine_ports::{CombatStats};
408    use wowlab_engine_telemetry::{TelemetrySink};
409
410    use wowlab_types::sim::SimTime;
411
412    use super::*;
413    use crate::generated::specs::retribution_paladin::AURA;
414
415    /// Manifest aura order up to Divine Purpose; the local indices are generated from it.
416    const AURA_PREFIX: [(&str, u32); 4] = [
417        ("avenging_wrath", AURA::AVENGING_WRATH),
418        ("empyrean_power", AURA::EMPYREAN_POWER),
419        ("empyrean_legacy", AURA::EMPYREAN_LEGACY),
420        ("divine_purpose", AURA::DIVINE_PURPOSE),
421    ];
422
423    #[gtest]
424    fn a_holy_power_spender_consumes_divine_purpose_before_rerolling_it() -> Result<()> {
425        let mut builder = crate::test_combat_builder(CombatStats::default());
426
427        for (name, aura_id) in AURA_PREFIX {
428            builder = builder.aura(name, aura_id, |a| Ok(a.duration_ms(12_000)));
429        }
430
431        let mut built = builder
432            .build(crate::test_support::wait_rotation())
433            .or_fail()?;
434
435        verify_that!(
436            built.state.aura_local(AURA::DIVINE_PURPOSE),
437            some(eq(AURA_DIVINE_PURPOSE))
438        )?;
439
440        let mut sink = TelemetrySink::default();
441        let mut rng = || 0.5;
442        let mut ctx = HookCtx::new(
443            wowlab_engine_combat::HookCtxServices {
444                state: &mut built.state,
445                buf: &mut built.buffer,
446                sink: &mut sink,
447                rng: &mut rng,
448            },
449            wowlab_engine_combat::HookCtxRequest::targetless(SimTime::ZERO),
450        );
451
452        ctx.apply_aura(AURA_DIVINE_PURPOSE.raw());
453
454        verify_true!(ctx.is_aura_active(AURA_DIVINE_PURPOSE.raw()))?;
455
456        // The default config leaves the re-roll chance at zero, so a live buff can only survive
457        // this call if the spender never consumed it.
458        divine_purpose_cycle(&mut ctx);
459
460        verify_false!(ctx.is_aura_active(AURA_DIVINE_PURPOSE.raw()))
461    }
462
463    #[gtest]
464    fn empyrean_legacy_divine_storm_consumes_the_trigger_and_free_spender_buffs() -> Result<()> {
465        let mut builder = crate::test_combat_builder(CombatStats::default());
466
467        for (name, aura_id) in AURA_PREFIX {
468            builder = builder.aura(name, aura_id, |a| Ok(a.duration_ms(12_000)));
469        }
470
471        let mut built = builder
472            .build(crate::test_support::wait_rotation())
473            .or_fail()?;
474        let mut sink = TelemetrySink::default();
475        let mut rng = || 0.5;
476        let mut ctx = HookCtx::new(
477            wowlab_engine_combat::HookCtxServices {
478                state: &mut built.state,
479                buf: &mut built.buffer,
480                sink: &mut sink,
481                rng: &mut rng,
482            },
483            wowlab_engine_combat::HookCtxRequest::targetless(SimTime::ZERO),
484        );
485
486        ctx.apply_aura(AURA_EMPYREAN_LEGACY.raw());
487        ctx.apply_aura(AURA_EMPYREAN_POWER.raw());
488        ctx.apply_aura(AURA_DIVINE_PURPOSE.raw());
489
490        verify_true!(trigger_empyrean_legacy_divine_storm(&mut ctx, 1.25, 0.0,))?;
491        verify_false!(ctx.is_aura_active(AURA_EMPYREAN_LEGACY.raw()))?;
492        verify_false!(ctx.is_aura_active(AURA_EMPYREAN_POWER.raw()))?;
493        verify_false!(ctx.is_aura_active(AURA_DIVINE_PURPOSE.raw()))?;
494
495        verify_false!(trigger_empyrean_legacy_divine_storm(&mut ctx, 1.25, 0.0,))
496    }
497
498    #[gtest]
499    fn vengeful_wrath_scales_linearly_to_full_effect_at_twenty_percent_health() -> Result<()> {
500        verify_that!(vengeful_wrath_multiplier(0.5, 1.0), near(1.0, f64::EPSILON))?;
501        verify_that!(
502            vengeful_wrath_multiplier(0.5, 0.6),
503            near(1.25, f64::EPSILON)
504        )?;
505        verify_that!(vengeful_wrath_multiplier(0.5, 0.2), near(1.5, f64::EPSILON))?;
506
507        verify_that!(vengeful_wrath_multiplier(0.5, 0.0), near(1.5, f64::EPSILON))
508    }
509
510    #[gtest]
511    fn greater_judgment_applies_two_debuff_stacks_per_judgment_family_hit() -> Result<()> {
512        verify_that!(judgment_debuff_stacks(false), eq(1))?;
513
514        verify_that!(judgment_debuff_stacks(true), eq(2))
515    }
516}