Skip to main content

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