wowlab_engine_content/hooks/windwalker_monk/
hooks.rs1use 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
16const TIGER_PALM_CHI: f64 = 2.0;
18
19const WDP_AOE_PULSES: u32 = 3;
21
22const WDP_GRACE_PERIOD_MS: f64 = 1500.0;
24
25#[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
34fn haste_frac(ctx: &HookCtx<'_>) -> f64 {
36 ctx.buf().player().haste / HUNDRED
37}
38
39#[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
61pub(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
236fn 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
341pub(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 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}