Skip to main content

wowlab_engine_content/hooks/windwalker_monk/
hooks.rs

1use wowlab_engine_combat::{
2    AuraOps as _, CooldownOps as _, DamageEffectId, DamageFlags, DamageOps as _, HookCtx,
3    MasteryCtx, ProcOps as _, ResourceOps as _, SwingEvent, SwingHand, SwingReplacement,
4};
5use wowlab_engine_rng::proc_chance;
6use wowlab_types::constants::HUNDRED;
7
8use super::config::{WindwalkerConfig, WindwalkerRuntime, windwalker_config};
9use crate::generated::specs::windwalker_monk::{
10    AURA_BLACKOUT_KICK, AURA_COMBAT_WISDOM, AURA_COMBO_STRIKES, AURA_DANCE_OF_CHI_JI,
11    AURA_FLURRY_CHARGE, AURA_MOMENTUM_BOOST, AURA_STAND_READY, AURA_TEACHINGS_OF_THE_MONASTERY,
12    AURA_UNBROKEN_RHYTHM, AURA_WHIRLING_DRAGON_PUNCH, AURA_ZENITH, DAMAGE_EFFECT, DAMAGE_REPORTED,
13    EFFECT, HERO, REPORTED_SPELL, SPELL_FISTS_OF_FURY, SPELL_RISING_SUN_KICK,
14};
15
16/// Tiger Palm's server-side chi gain is two.
17const TIGER_PALM_CHI: f64 = 2.0;
18
19/// `AoE` pulses per Whirling Dragon Punch: ticks 0..2, last tick skipped.
20const WDP_AOE_PULSES: u32 = 3;
21
22/// Measured grace period on the Whirling Dragon Punch usability window.
23const WDP_GRACE_PERIOD_MS: f64 = 1500.0;
24
25/// Hook-dealt spells excluded from Windwalker mastery.
26#[rustfmt::skip]
27const NO_WW_MASTERY: &[u32] = &[
28    REPORTED_SPELL::TEACHINGS_BLACKOUT_KICK,
29    HERO::SHADO_PAN::SPELL::FLURRY_STRIKE,
30    HERO::SHADO_PAN::SPELL::SHADO_OVER_THE_BATTLEFIELD,
31    EFFECT::EH_DMG.0,
32];
33
34/// Returns sheet haste without temporary buffs.
35fn haste_frac(ctx: &HookCtx<'_>) -> f64 {
36    ctx.buf().player().haste / HUNDRED
37}
38
39/// Applies Combo Strikes mastery to eligible actions.
40#[must_use]
41pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
42    if ctx.spell_id == REPORTED_SPELL::MELEE || NO_WW_MASTERY.contains(&ctx.spell_id) {
43        return 1.0;
44    }
45
46    if !ctx.is_aura_active(AURA_COMBO_STRIKES) {
47        return 1.0;
48    }
49
50    ctx.bonus(1)
51}
52
53fn ap(ctx: &HookCtx<'_>, effect: DamageEffectId) -> f64 {
54    ctx.effect_ap_coefficient(effect)
55}
56
57fn base(ctx: &HookCtx<'_>, effect: DamageEffectId) -> f64 {
58    ctx.effect_base_points(effect)
59}
60
61/// Selects Dual Threat in place of a landed white swing.
62pub(super) fn dual_threat_replacement(
63    ctx: &mut HookCtx<'_>,
64    _hand: SwingHand,
65) -> Option<SwingReplacement> {
66    let cfg = windwalker_config(ctx);
67    let mut runtime = ctx.spec_runtime::<WindwalkerRuntime>().copied()?;
68    let should_replace =
69        dual_threat_should_replace(cfg.dual_threat_chance, &mut runtime, ctx.rng());
70
71    *ctx.spec_runtime_mut::<WindwalkerRuntime>()? = runtime;
72
73    if !should_replace {
74        return None;
75    }
76
77    Some(SwingReplacement {
78        action: DAMAGE_REPORTED::DUAL_THREAT,
79        damage_multiplier: cfg.dual_threat_damage_mult,
80    })
81}
82
83fn dual_threat_should_replace(
84    chance: f64,
85    runtime: &mut WindwalkerRuntime,
86    rng: &mut dyn FnMut() -> f64,
87) -> bool {
88    if chance <= 0.0 {
89        return false;
90    }
91
92    if !runtime.dual_threat_ready {
93        runtime.dual_threat_ready = true;
94
95        return false;
96    }
97
98    if !proc_chance(rng, chance) {
99        return false;
100    }
101
102    runtime.dual_threat_ready = false;
103
104    true
105}
106
107fn sp(ctx: &HookCtx<'_>, effect: DamageEffectId) -> f64 {
108    ctx.effect_sp_coefficient(effect)
109}
110
111fn roll_dance_of_chi_ji(ctx: &mut HookCtx<'_>, _cfg: &WindwalkerConfig) {
112    if ctx
113        .roll_system_rppm(wowlab_engine_combat::generated_identities::system_rppm::DANCE_OF_CHI_JI)
114    {
115        ctx.apply_aura(AURA_DANCE_OF_CHI_JI);
116    }
117}
118
119fn flurry_volley(ctx: &mut HookCtx<'_>, cfg: &WindwalkerConfig, count: i32, strike_mult: f64) {
120    for _ in 0..count {
121        ctx.deal_damage_ap(
122            crate::generated::specs::windwalker_monk::DAMAGE_SPELL::FLURRY_STRIKE,
123            cfg.flurry_coef * strike_mult,
124            DamageFlags::PHYSICAL,
125        );
126        ctx.deal_damage_ap(
127            crate::generated::specs::windwalker_monk::DAMAGE_SPELL::SHADO_OVER_THE_BATTLEFIELD,
128            cfg.shado_coef,
129            DamageFlags::empty(),
130        );
131    }
132}
133
134fn release_flurry_charges(ctx: &mut HookCtx<'_>, cfg: &WindwalkerConfig) {
135    if !cfg.flurry_strikes {
136        return;
137    }
138
139    let count = ctx.aura_stacks(AURA_FLURRY_CHARGE);
140
141    if count <= 0 {
142        return;
143    }
144
145    ctx.expire_aura(AURA_FLURRY_CHARGE);
146    flurry_volley(ctx, cfg, count, 1.0);
147}
148
149fn release_wisdom_of_the_wall(ctx: &mut HookCtx<'_>, cfg: &WindwalkerConfig) {
150    if !cfg.wisdom_of_the_wall
151        || !ctx.is_aura_active(AURA_ZENITH)
152        || !ctx.proc_category_ready(
153            wowlab_engine_combat::generated_identities::proc_category::WISDOM_OF_THE_WALL,
154        )
155    {
156        return;
157    }
158
159    ctx.start_proc_category_cooldown(
160        wowlab_engine_combat::generated_identities::proc_category::WISDOM_OF_THE_WALL,
161    );
162    flurry_volley(ctx, cfg, cfg.wotw_count, 1.0);
163}
164
165fn release_stand_ready(ctx: &mut HookCtx<'_>, cfg: &WindwalkerConfig) {
166    if !cfg.stand_ready || !ctx.is_aura_active(AURA_STAND_READY) {
167        return;
168    }
169
170    ctx.expire_aura(AURA_STAND_READY);
171    flurry_volley(ctx, cfg, cfg.stand_ready_count, cfg.stand_ready_mult);
172}
173
174pub(crate) fn tiger_palm_hook(ctx: &mut HookCtx<'_>) {
175    ctx.gain_secondary_resource(TIGER_PALM_CHI);
176
177    let cfg = windwalker_config(ctx);
178
179    if cfg.teachings {
180        ctx.apply_aura(AURA_TEACHINGS_OF_THE_MONASTERY);
181    }
182
183    if cfg.combo_breaker_chance > 0.0 && proc_chance(ctx.rng(), cfg.combo_breaker_chance) {
184        ctx.apply_aura(AURA_BLACKOUT_KICK);
185    }
186
187    if cfg.combat_wisdom && ctx.is_aura_active(AURA_COMBAT_WISDOM) {
188        ctx.expire_aura(AURA_COMBAT_WISDOM);
189        let heal_mult = ctx.action_passive_direct_damage_mult(DAMAGE_EFFECT::EH_HEAL_SP.action());
190        let coef =
191            sp(ctx, DAMAGE_EFFECT::EH_HEAL_SP) * heal_mult * base(ctx, DAMAGE_EFFECT::EH_DMG_PCT)
192                / HUNDRED;
193
194        ctx.deal_damage_sp(DAMAGE_EFFECT::EH_DMG.action(), coef, DamageFlags::empty());
195    }
196
197    release_stand_ready(ctx, &cfg);
198}
199
200pub(crate) fn blackout_kick_hook(ctx: &mut HookCtx<'_>) {
201    let cfg = windwalker_config(ctx);
202
203    ctx.consume_aura_stack(AURA_BLACKOUT_KICK);
204
205    let mut reset_rolls = 1;
206
207    if cfg.teachings {
208        let stacks = ctx.aura_stacks(AURA_TEACHINGS_OF_THE_MONASTERY);
209
210        if stacks > 0 {
211            ctx.expire_aura(AURA_TEACHINGS_OF_THE_MONASTERY);
212            let coef = ap(ctx, DAMAGE_EFFECT::TOTM_BOK_DMG);
213
214            for _ in 0..stacks {
215                ctx.deal_damage_ap(
216                    DAMAGE_EFFECT::TOTM_BOK_DMG.action(),
217                    coef,
218                    DamageFlags::PHYSICAL,
219                );
220            }
221
222            reset_rolls += stacks;
223        }
224    }
225
226    for _ in 0..reset_rolls {
227        if cfg.totm_reset_chance > 0.0 && proc_chance(ctx.rng(), cfg.totm_reset_chance) {
228            ctx.reset_cooldown(SPELL_RISING_SUN_KICK);
229        }
230    }
231
232    roll_dance_of_chi_ji(ctx, &cfg);
233    release_stand_ready(ctx, &cfg);
234}
235
236/// Opens Whirling Dragon Punch's usability window for 1.5 seconds times melee haste plus the shorter active gating cooldown.
237fn open_whirling_dragon_punch_window(ctx: &mut HookCtx<'_>) {
238    let gate_ms = ctx
239        .spell_cooldown_remaining_ms(SPELL_RISING_SUN_KICK)
240        .min(ctx.spell_cooldown_remaining_ms(SPELL_FISTS_OF_FURY));
241
242    if gate_ms == 0 {
243        return;
244    }
245
246    let grace_ms = WDP_GRACE_PERIOD_MS / (1.0 + haste_frac(ctx));
247
248    ctx.apply_aura_with_duration(
249        AURA_WHIRLING_DRAGON_PUNCH,
250        gate_ms + wowlab_types::numeric::f64_to_u32_saturating_round(grace_ms),
251    );
252}
253
254pub(crate) fn rising_sun_kick_hook(ctx: &mut HookCtx<'_>) {
255    let cfg = windwalker_config(ctx);
256
257    open_whirling_dragon_punch_window(ctx);
258
259    if cfg.glory_of_the_dawn {
260        let chance = base(ctx, DAMAGE_EFFECT::GOTD_CHANCE) / HUNDRED * haste_frac(ctx);
261
262        if proc_chance(ctx.rng(), chance) {
263            let coef = ap(ctx, DAMAGE_EFFECT::GOTD_DMG);
264
265            ctx.deal_damage_ap(
266                DAMAGE_EFFECT::GOTD_DMG.action(),
267                coef,
268                DamageFlags::PHYSICAL,
269            );
270            ctx.gain_secondary_resource(base(ctx, DAMAGE_EFFECT::GOTD_CHI));
271        }
272    }
273
274    release_wisdom_of_the_wall(ctx, &cfg);
275    roll_dance_of_chi_ji(ctx, &cfg);
276    release_stand_ready(ctx, &cfg);
277    ctx.expire_aura(AURA_UNBROKEN_RHYTHM);
278}
279
280pub(crate) fn fists_of_fury_hook(ctx: &mut HookCtx<'_>) {
281    let cfg = windwalker_config(ctx);
282
283    if cfg.mid2_fof_extra_mult > 0.0 {
284        deal_fists_of_fury_hit(ctx, &cfg, cfg.mid2_fof_extra_mult);
285    }
286
287    open_whirling_dragon_punch_window(ctx);
288    release_flurry_charges(ctx, &cfg);
289    roll_dance_of_chi_ji(ctx, &cfg);
290    release_stand_ready(ctx, &cfg);
291}
292
293pub(crate) fn spinning_crane_kick_hook(ctx: &mut HookCtx<'_>) {
294    let cfg = windwalker_config(ctx);
295    let unbroken_stacks = ctx.aura_stacks(AURA_UNBROKEN_RHYTHM);
296
297    if let Some(runtime) = ctx.spec_runtime_mut::<WindwalkerRuntime>() {
298        runtime.spinning_crane_kick_tier_bonus =
299            unbroken_sck_bonus(unbroken_stacks, cfg.mid2_sck_bonus_per_stack);
300    }
301
302    ctx.expire_aura(AURA_UNBROKEN_RHYTHM);
303
304    if ctx.consume_aura_stack(AURA_DANCE_OF_CHI_JI)
305        && cfg.sequenced_strikes_chance > 0.0
306        && proc_chance(ctx.rng(), cfg.sequenced_strikes_chance)
307    {
308        ctx.apply_aura(AURA_BLACKOUT_KICK);
309    }
310
311    release_wisdom_of_the_wall(ctx, &cfg);
312    roll_dance_of_chi_ji(ctx, &cfg);
313    release_stand_ready(ctx, &cfg);
314}
315
316fn unbroken_sck_bonus(stacks: i32, bonus_per_stack: f64) -> f64 {
317    f64::from(stacks.max(0)) * bonus_per_stack
318}
319
320pub(crate) fn spinning_crane_kick_tick_hook(ctx: &mut HookCtx<'_>) {
321    let bonus = ctx
322        .spec_runtime::<WindwalkerRuntime>()
323        .map_or(0.0, |runtime| runtime.spinning_crane_kick_tier_bonus);
324
325    if bonus > 0.0 {
326        ctx.deal_effect_damage_ap_with_geometry(
327            DAMAGE_EFFECT::SCK_TICK,
328            DAMAGE_EFFECT::SCK_TICK,
329            bonus,
330            DamageFlags::PHYSICAL,
331        );
332    }
333
334    if ctx.is_last_channel_tick()
335        && let Some(runtime) = ctx.spec_runtime_mut::<WindwalkerRuntime>()
336    {
337        runtime.spinning_crane_kick_tier_bonus = 0.0;
338    }
339}
340
341/// Whirling Dragon Punch adds the 3 `AoE` pulses (158221) hitting the primary target; the ST tick (451767) is the manifest damage.
342pub(crate) fn whirling_dragon_punch_hook(ctx: &mut HookCtx<'_>) {
343    let effect = DAMAGE_EFFECT::WDP_AOE_TICK;
344    let coef = ctx.effect_ap_coefficient(effect);
345
346    for _ in 0..WDP_AOE_PULSES {
347        ctx.deal_damage_ap(effect.action(), coef, DamageFlags::PHYSICAL);
348    }
349}
350
351pub(crate) fn zenith_hook(ctx: &mut HookCtx<'_>) {
352    let cfg = windwalker_config(ctx);
353
354    ctx.reset_cooldown(SPELL_RISING_SUN_KICK);
355
356    if cfg.stand_ready {
357        ctx.apply_aura(AURA_STAND_READY);
358    }
359
360    if cfg.zenith_stomp {
361        let coef = ap(ctx, DAMAGE_EFFECT::ZENITH_STOMP_DMG);
362
363        ctx.deal_damage_ap(
364            DAMAGE_EFFECT::ZENITH_STOMP_DMG.action(),
365            coef,
366            DamageFlags::empty(),
367        );
368        ctx.gain_secondary_resource(base(ctx, DAMAGE_EFFECT::ZENITH_STOMP_CHI));
369    }
370}
371
372pub(crate) fn fists_of_fury_tick_hook(ctx: &mut HookCtx<'_>) {
373    let cfg = windwalker_config(ctx);
374
375    deal_fists_of_fury_hit(ctx, &cfg, 1.0);
376
377    if !ctx.is_last_channel_tick() {
378        return;
379    }
380
381    ctx.expire_aura(AURA_MOMENTUM_BOOST);
382
383    if cfg.jadefire_stomp {
384        let effect = DAMAGE_EFFECT::JADEFIRE_STOMP_DMG;
385        let coef = ctx.effect_ap_coefficient(effect);
386
387        ctx.deal_damage_ap(effect.action(), coef, DamageFlags::empty());
388    }
389}
390
391fn deal_fists_of_fury_hit(ctx: &mut HookCtx<'_>, cfg: &WindwalkerConfig, effectiveness: f64) {
392    let mut mult = 1.0;
393
394    // 451297 e1's per-stack damage is a live-aura DBC modifier the generic pipeline already folds.
395    // Only the haste-scaled term is hand-written.
396
397    if cfg.momentum_boost {
398        mult *= 1.0 + haste_frac(ctx) * base(ctx, DAMAGE_EFFECT::MOMENTUM_HASTE) / HUNDRED;
399    }
400
401    let coef = ap(ctx, DAMAGE_EFFECT::FOF_TICK);
402
403    ctx.deal_damage_ap(
404        DAMAGE_EFFECT::FOF_TICK.action(),
405        coef * mult * effectiveness,
406        DamageFlags::PHYSICAL,
407    );
408
409    if cfg.momentum_boost {
410        ctx.apply_aura(AURA_MOMENTUM_BOOST);
411    }
412
413    if cfg.mid2_unbroken_rhythm {
414        ctx.apply_aura(AURA_UNBROKEN_RHYTHM);
415    }
416}
417
418pub(crate) fn swing_hook(ctx: &mut HookCtx<'_>, event: SwingEvent) {
419    let is_crit = event.is_crit;
420
421    let cfg = windwalker_config(ctx);
422
423    if !cfg.flurry_strikes {
424        return;
425    }
426
427    let mut charges = cfg.flurry_charges_per_swing;
428
429    if is_crit {
430        charges *= cfg.flurry_crit_charge_mult;
431    }
432
433    ctx.apply_aura_n(AURA_FLURRY_CHARGE, charges);
434}
435
436#[cfg(test)]
437mod tests {
438    use googletest::prelude::*;
439
440    use super::*;
441
442    #[gtest]
443    fn dual_threat_requires_a_normal_swing_between_replacements() -> Result<()> {
444        let mut runtime = WindwalkerRuntime::default();
445        let mut unexpected_rolls = 0;
446
447        verify_that!(
448            dual_threat_should_replace(0.3, &mut runtime, &mut || {
449                unexpected_rolls += 1;
450
451                0.0
452            }),
453            eq(false)
454        )?;
455        verify_that!(unexpected_rolls, eq(0))?;
456        verify_that!(runtime.dual_threat_ready, eq(true))?;
457        verify_that!(
458            dual_threat_should_replace(0.3, &mut runtime, &mut || 0.1),
459            eq(true)
460        )?;
461        verify_that!(runtime.dual_threat_ready, eq(false))?;
462        verify_that!(
463            dual_threat_should_replace(0.3, &mut runtime, &mut || {
464                unexpected_rolls += 1;
465
466                0.0
467            }),
468            eq(false)
469        )?;
470        verify_that!(unexpected_rolls, eq(0))?;
471        verify_that!(
472            dual_threat_should_replace(0.3, &mut runtime, &mut || 0.9),
473            eq(false)
474        )?;
475        verify_that!(runtime.dual_threat_ready, eq(true))?;
476
477        verify_that!(
478            dual_threat_should_replace(0.3, &mut runtime, &mut || 0.1),
479            eq(true)
480        )
481    }
482
483    #[gtest]
484    fn unbroken_rhythm_snapshots_the_sck_bonus_per_stack() -> Result<()> {
485        verify_that!(unbroken_sck_bonus(0, 0.20), eq(0.0))?;
486
487        verify_that!(unbroken_sck_bonus(6, 0.20), near(1.20, f64::EPSILON))
488    }
489}