wowlab_engine_content/hooks/retribution_paladin/
hooks.rs1use 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
34pub(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
47pub(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
101pub(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
168pub(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
185pub(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
213pub(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
240fn 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
291pub(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
357pub(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
376pub(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
389pub(crate) fn avenging_wrath_expire_hook(ctx: &mut HookCtx<'_>) {
391 ctx.expire_aura(AURA_HAMMER_OF_WRATH.raw());
392}
393
394#[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 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 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}