wowlab_engine_content/hooks/feral_druid/
hooks.rs1use wowlab_engine_combat::DamageOps as _;
2use wowlab_engine_combat::ResourceOps as _;
3use wowlab_engine_combat::SchedulingOps as _;
4use wowlab_engine_combat::AuraOps as _;
5use wowlab_engine_combat::{
6 CombatProgram, DamageFlags, FreeActionSource, HookCtx, ImpactEvent, MasteryCtx, SwingEvent,
7};
8use wowlab_engine_rng::{ppm_proc_chance, proc_chance, roll_range, shuffle_pick};
9use wowlab_types::constants::MS_PER_SECOND;
10
11use crate::generated::specs::feral_druid::{
12 AURA, AURA_APEX_PREDATORS_CRAVING, AURA_BERSERK, AURA_BLOODSEEKER_VINES, AURA_CLEARCASTING,
13 AURA_HALAZZIS_FURY, AURA_INCARNATION_AVATAR_OF_ASHAMANE, AURA_LYCARAS_TEACHINGS_CRIT,
14 AURA_MOONFIRE, AURA_OVERFLOWING_POWER, AURA_RAKE, AURA_RIP, AURA_SABERTOOTH,
15 AURA_SAVAGE_FURY, AURA_STALKING_PREDATOR, AURA_STRATEGIC_INFUSION, AURA_SUDDEN_AMBUSH,
16 AURA_TIGERS_TENACITY,
17 AURA_UNSEEN_PREDATORS_CRAVING, AURA_UNSEEN_SLASH, EFFECT, SPELL, SPELL_FERAL_FRENZY,
18 SPELL_FEROCIOUS_BITE, SPELL_MOONFIRE, SPELL_RAKE, SPELL_SHRED, SPELL_WRATH,
19};
20
21use super::config::{ConvokeCast, FeralConfig, FeralRuntime, feral_config};
22
23const MS_PER_CP_RIP: u32 = 3200;
25const FB_MAX_EXCESS_ENERGY: f64 = 25.0;
27const MAX_COMBO_POINTS: f64 = 5.0;
29const UNSEEN_CHANCE_PER_CP: f64 = 0.15;
31const UNSEEN_ATTACK_MULT: f64 = 1.30;
33const CRAVING_MS_PER_CP: u32 = 1000;
35const STALKING_PREDATOR_STACKS: i32 = 2;
37const TIGERS_TENACITY_STACKS: i32 = 3;
39const FERAL_FRENZY_HITS: u32 = 5;
41const FERAL_FRENZY_CP: f64 = 5.0;
43const SOTF_ENERGY_PER_CP: f64 = 2.0;
45const PANTHERS_GUILE_CHANCE: f64 = 0.25;
47const PRIMAL_FURY_CP: f64 = 1.0;
49const OMEN_OF_CLARITY_PPM: f64 = 7.0;
51const GAUSSIAN_MAX_ATTEMPTS: u8 = 8;
52const BOX_MULLER_LOG_SCALE: f64 = -2.0;
53const UNIT_INTERVAL_MIN: f64 = 0.0;
54const UNIT_INTERVAL_MAX: f64 = 1.0;
55const EXCEPTIONAL_DECK_SHUFFLE_CHANCE: f64 = 0.5;
56const GUIDED_OFFSPEC_MIN: f64 = 2.5;
57const STANDARD_OFFSPEC_MIN: f64 = 4.0;
58const OFFSPEC_RANGE_WIDTH: f64 = 5.0;
59const GUIDED_BITE_MEAN: f64 = 3.0;
60const GUIDED_BITE_STANDARD_DEVIATION: f64 = 0.8;
61const STANDARD_BITE_MEAN: f64 = 4.2;
62const STANDARD_BITE_STANDARD_DEVIATION: f64 = 0.936_089_005_5;
63const CONVOKE_HEAL_WEIGHT: f64 = 0.35;
64const CONVOKE_MOONFIRE_WEIGHT: f64 = 0.5;
65const CONVOKE_WRATH_WEIGHT: f64 = 0.15;
66const CONVOKE_SHRED_WEIGHT: f64 = 0.10;
67const CONVOKE_RAKE_WEIGHT: f64 = 0.22;
68const CONVOKE_OFFSPEC_ACTIONS: [(Option<wowlab_engine_combat::LocalSpellIdx>, f64); 3] = [
69 (None, CONVOKE_HEAL_WEIGHT),
70 (Some(SPELL_MOONFIRE), CONVOKE_MOONFIRE_WEIGHT),
71 (Some(SPELL_WRATH), CONVOKE_WRATH_WEIGHT),
72];
73const CONVOKE_SPEC_ACTIONS: [(Option<wowlab_engine_combat::LocalSpellIdx>, f64); 2] = [
74 (Some(SPELL_SHRED), CONVOKE_SHRED_WEIGHT),
75 (Some(SPELL_RAKE), CONVOKE_RAKE_WEIGHT),
76];
77
78pub(super) const fn builder_filter(spell_id: u32) -> bool {
79 matches!(
80 spell_id,
81 SPELL::SHRED | SPELL::RAKE | SPELL::MOONFIRE | SPELL::SWIPE
82 )
83}
84
85pub(super) const fn rip_filter(spell_id: u32) -> bool {
86 spell_id == SPELL::RIP
87}
88
89pub(super) const fn rip_or_rake_filter(spell_id: u32) -> bool {
90 spell_id == SPELL::RIP || spell_id == AURA::RAKE
91}
92
93pub(super) fn thriving_growth_contribution(program: &CombatProgram, impact: ImpactEvent) -> f64 {
94 let Some(config) = program.spec_config::<FeralConfig>() else {
95 return 0.0;
96 };
97
98 match impact.spell_id {
99 SPELL::RIP => config.thriving_rip_contribution,
100 AURA::RAKE => config.thriving_rake_contribution,
101 _ => 0.0,
102 }
103}
104
105pub(super) fn bloodseeker_vines_fire(
106 ctx: &mut HookCtx<'_>,
107 _impact: ImpactEvent,
108) {
109 ctx.apply_aura(AURA_BLOODSEEKER_VINES);
110}
111
112pub(super) fn apex_predator_fire(
113 ctx: &mut HookCtx<'_>,
114 _impact: ImpactEvent,
115) {
116 ctx.apply_aura(AURA_APEX_PREDATORS_CRAVING);
117}
118
119pub(super) fn primal_fury_fire(
120 ctx: &mut HookCtx<'_>,
121 _impact: ImpactEvent,
122) {
123 gain_cp_overflowing(ctx, PRIMAL_FURY_CP);
124}
125
126fn gain_cp_overflowing(ctx: &mut HookCtx<'_>, amount: f64) {
127 let head_room = MAX_COMBO_POINTS - ctx.secondary_resource();
128 let over = amount - head_room;
129
130 ctx.gain_secondary_resource(amount);
131
132 if over > 0.0 && ctx.is_aura_active(AURA_BERSERK) {
133 for _ in 0..wowlab_types::numeric::f64_to_u32_saturating_ceil(over) {
134 ctx.add_aura_stack(AURA_OVERFLOWING_POWER);
135 }
136 }
137}
138
139fn finisher_effects(ctx: &mut HookCtx<'_>, cp: f64) {
140 let config = feral_config(ctx);
141
142 if cp > 0.0
143 && (ctx.is_aura_active(AURA_BERSERK)
144 || ctx.is_aura_active(AURA_INCARNATION_AVATAR_OF_ASHAMANE))
145 {
146 let duration_ms = halazzis_fury_increment_ms(
147 cp,
148 config.halazzis_fury_ms_per_cp,
149 config.halazzis_fury_guided_ms_per_cp,
150 ctx.is_aura_active(AURA_INCARNATION_AVATAR_OF_ASHAMANE)
151 && config.ashamanes_guidance,
152 );
153
154 if let Some(runtime) = ctx.spec_runtime_mut::<FeralRuntime>() {
155 runtime.halazzis_fury_duration_ms = runtime
156 .halazzis_fury_duration_ms
157 .saturating_add(duration_ms);
158 }
159 }
160
161 if config.soul_of_the_forest {
162 ctx.gain_resource(SOTF_ENERGY_PER_CP * cp);
163 }
164
165 if proc_chance(ctx.rng(), config.sudden_ambush_chance * cp) {
166 ctx.apply_aura(AURA_SUDDEN_AMBUSH);
167 }
168
169 if ctx.consume_aura_stack(AURA_TIGERS_TENACITY) {
170 ctx.gain_secondary_resource(1.0);
171 }
172
173 let op_stacks = ctx.aura_stacks(AURA_OVERFLOWING_POWER);
174
175 if op_stacks > 0 {
176 ctx.expire_aura(AURA_OVERFLOWING_POWER);
177 ctx.gain_secondary_resource(f64::from(op_stacks));
178 }
179}
180
181fn halazzis_fury_increment_ms(
182 effective_cp: f64,
183 normal_ms_per_cp: u32,
184 guided_ms_per_cp: u32,
185 guided_incarnation: bool,
186) -> u32 {
187 let per_cp_ms = if guided_incarnation {
188 guided_ms_per_cp
189 } else {
190 normal_ms_per_cp
191 };
192
193 wowlab_types::numeric::f64_to_u32_saturating_trunc(
194 effective_cp.max(0.0) * f64::from(per_cp_ms),
195 )
196}
197
198#[must_use]
200pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
201 ctx.direct_or_periodic_affected_bonus_any(
202 &[
203 EFFECT::RAZOR_CLAWS_DAMAGE.1,
204 EFFECT::RAZOR_CLAWS_RAMPANT_DIRECT.1,
205 ],
206 &[
207 EFFECT::RAZOR_CLAWS_PERIODIC.1,
208 EFFECT::RAZOR_CLAWS_RAMPANT_PERIODIC.1,
209 EFFECT::RAZOR_CLAWS_BLOODSEEKER_PERIODIC.1,
210 EFFECT::RAZOR_CLAWS_DREADFUL_WOUND_PERIODIC.1,
211 EFFECT::RAZOR_CLAWS_UNSEEN_SLASH_PERIODIC.1,
212 ],
213 )
214}
215
216fn unseen_attack(ctx: &mut HookCtx<'_>, cp: f64, energy_mod: f64) {
217 let scale = (cp / MAX_COMBO_POINTS) * energy_mod;
218 let coef = ctx.effect_ap_coefficient(crate::generated::specs::feral_druid::DAMAGE_EFFECT::UNSEEN_SLASH_DMG) * UNSEEN_ATTACK_MULT * scale;
219
220 ctx.deal_damage_ap(crate::generated::specs::feral_druid::DAMAGE_EFFECT::UNSEEN_SLASH_DMG.action(), coef, DamageFlags::PHYSICAL);
221 ctx.apply_aura(AURA_UNSEEN_SLASH);
222
223 ctx.apply_aura(AURA_UNSEEN_PREDATORS_CRAVING);
224 let extra_cp =
225 wowlab_types::numeric::f64_to_u32_saturating_trunc(cp.max(1.0)).saturating_sub(1);
226
227 if extra_cp > 0 {
228 ctx.extend_aura(
229 AURA_UNSEEN_PREDATORS_CRAVING,
230 extra_cp * CRAVING_MS_PER_CP,
231 );
232 }
233}
234
235pub(crate) fn swing_hook(
236 ctx: &mut HookCtx<'_>,
237 event: SwingEvent,
238) {
239 let hand = event.hand;
240
241 let config = feral_config(ctx);
242
243 if !config.omen_of_clarity {
244 return;
245 }
246
247 let swing_period_seconds = ctx
248 .player_swing_period_ms(hand)
249 .map_or(0.0, |period_ms| f64::from(period_ms) / MS_PER_SECOND);
250 let ppm = OMEN_OF_CLARITY_PPM * (1.0 + config.moment_of_clarity_proc_bonus);
251
252 if proc_chance(ctx.rng(), ppm_proc_chance(ppm, swing_period_seconds)) {
253 ctx.apply_aura(AURA_CLEARCASTING);
254 }
255}
256
257pub(crate) fn halazzis_fury_expire_hook(ctx: &mut HookCtx<'_>) {
258 if ctx.is_aura_active(AURA_BERSERK)
259 || ctx.is_aura_active(AURA_INCARNATION_AVATAR_OF_ASHAMANE)
260 {
261 return;
262 }
263
264 let duration_ms = ctx
265 .spec_runtime_mut::<FeralRuntime>()
266 .map_or(0, |runtime| {
267 std::mem::take(&mut runtime.halazzis_fury_duration_ms)
268 });
269
270 if duration_ms > 0 {
271 ctx.apply_aura_with_duration(AURA_HALAZZIS_FURY, duration_ms);
272 }
273}
274
275pub(crate) fn cat_form_hook(ctx: &mut HookCtx<'_>) {
276 if feral_config(ctx).lycaras_teachings {
277 ctx.apply_aura(AURA_LYCARAS_TEACHINGS_CRIT);
278 }
279}
280
281pub(crate) fn builder_hook(ctx: &mut HookCtx<'_>) {
282 if ctx.is_aura_active(AURA_BERSERK) {
283 gain_cp_overflowing(ctx, 1.0);
284 }
285
286 if ctx.consume_aura_stack(AURA_STALKING_PREDATOR) {
287 unseen_attack(ctx, MAX_COMBO_POINTS, 1.0);
288 }
289}
290
291pub(crate) fn rake_hook(ctx: &mut HookCtx<'_>) {
292 builder_hook(ctx);
293}
294
295fn panthers_guile(ctx: &mut HookCtx<'_>) {
296 if !feral_config(ctx).panthers_guile {
297 return;
298 }
299
300 let missing = MAX_COMBO_POINTS - ctx.secondary_resource();
301
302 if missing > 0.0 && proc_chance(ctx.rng(), PANTHERS_GUILE_CHANCE) {
303 ctx.gain_secondary_resource(missing);
304 }
305}
306
307pub(crate) fn moonfire_hook(ctx: &mut HookCtx<'_>) {
308 builder_hook(ctx);
309 panthers_guile(ctx);
310}
311
312pub(crate) fn tigers_fury_hook(ctx: &mut HookCtx<'_>) {
313 ctx.apply_aura_n(AURA_STALKING_PREDATOR, STALKING_PREDATOR_STACKS);
314 ctx.apply_aura(AURA_SAVAGE_FURY);
315
316 if feral_config(ctx).tigers_tenacity {
317 ctx.apply_aura_n(AURA_TIGERS_TENACITY, TIGERS_TENACITY_STACKS);
318 }
319
320 if feral_config(ctx).strategic_infusion {
321 ctx.apply_aura(AURA_STRATEGIC_INFUSION);
322 }
323}
324
325pub(crate) fn shred_hook(ctx: &mut HookCtx<'_>) {
326 builder_hook(ctx);
327 panthers_guile(ctx);
328 ctx.consume_aura_stack(AURA_CLEARCASTING);
329}
330
331pub(crate) fn rip_hook(ctx: &mut HookCtx<'_>) {
332 let spent_cp = ctx.last_secondary_spent().max(0.0);
333 let cp = if ctx.free_action_source() == Some(FreeActionSource::ChannelChild) {
334 MAX_COMBO_POINTS
335 } else {
336 spent_cp
337 };
338 let whole_cp = wowlab_types::numeric::f64_to_u32_saturating_trunc(cp);
339
340 if whole_cp > 0 {
341 ctx.extend_aura(AURA_RIP, whole_cp * MS_PER_CP_RIP);
342 }
343
344 finisher_effects(ctx, cp);
345}
346
347pub(crate) fn ferocious_bite_hook(ctx: &mut HookCtx<'_>) {
348 let apex = ctx.is_aura_active(AURA_APEX_PREDATORS_CRAVING);
349 let free = ctx.free_action_source() == Some(FreeActionSource::ChannelChild);
350 let spent_cp = ctx.last_secondary_spent().max(0.0);
351 let cp = if apex || free {
352 MAX_COMBO_POINTS
353 } else {
354 spent_cp.max(1.0)
355 };
356
357 finisher_effects(ctx, cp);
358
359 if feral_config(ctx).sabertooth {
360 ctx.apply_aura_n(
361 AURA_SABERTOOTH,
362 wowlab_types::numeric::f64_to_i32_saturating_trunc(cp),
363 );
364 }
365
366 let excess = if apex || free {
367 FB_MAX_EXCESS_ENERGY
368 } else {
369 ctx.last_optional_primary_spent()
370 .clamp(0.0, FB_MAX_EXCESS_ENERGY)
371 };
372 let energy_mod = excess / FB_MAX_EXCESS_ENERGY;
373 let cp_mod = cp / MAX_COMBO_POINTS;
374
375 let coef = ctx.effect_ap_coefficient(crate::generated::specs::feral_druid::DAMAGE_EFFECT::FEROCIOUS_BITE_DMG) * cp_mod * (1.0 + energy_mod);
376
377 ctx.deal_damage_ap(
378 crate::generated::specs::feral_druid::DAMAGE_EFFECT::FEROCIOUS_BITE_DMG.action(),
379 coef,
380 DamageFlags::PHYSICAL,
381 );
382
383 if proc_chance(ctx.rng(), UNSEEN_CHANCE_PER_CP * cp) {
384 unseen_attack(ctx, cp, energy_mod);
385 }
386
387 if apex && !free {
388 ctx.consume_aura_stack(AURA_APEX_PREDATORS_CRAVING);
389 }
390}
391
392pub(crate) fn feral_frenzy_hook(ctx: &mut HookCtx<'_>) {
393 let hit = ctx.effect_ap_coefficient(crate::generated::specs::feral_druid::DAMAGE_EFFECT::FERAL_FRENZY_HIT);
394
395 for _ in 0..FERAL_FRENZY_HITS {
396 ctx.deal_damage_ap(crate::generated::specs::feral_druid::DAMAGE_EFFECT::FERAL_FRENZY_HIT.action(), hit, DamageFlags::PHYSICAL);
397 }
398
399 ctx.gain_secondary_resource(FERAL_FRENZY_CP);
400}
401
402fn bounded_gaussian_count(
403 rng: &mut dyn FnMut() -> f64,
404 mean: f64,
405 standard_deviation: f64,
406 maximum: usize,
407) -> usize {
408 if maximum == 0 {
409 return 0;
410 }
411
412 let maximum_f64 = f64::from(wowlab_types::numeric::usize_to_u32_saturating(maximum));
413
414 for _ in 0..GAUSSIAN_MAX_ATTEMPTS {
415 let u1 = roll_range(rng, f64::MIN_POSITIVE, UNIT_INTERVAL_MAX);
416 let u2 = roll_range(rng, UNIT_INTERVAL_MIN, UNIT_INTERVAL_MAX);
417 let standard_normal =
418 (BOX_MULLER_LOG_SCALE * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
419 let sample = mean + standard_deviation * standard_normal;
420
421 if (0.0..=maximum_f64).contains(&sample) {
422 return wowlab_types::numeric::f64_to_usize_saturating_trunc(sample);
423 }
424 }
425
426 wowlab_types::numeric::f64_to_usize_saturating_trunc(mean.clamp(0.0, maximum_f64))
427}
428
429fn convoke_exceptional_roll(ctx: &mut HookCtx<'_>) -> bool {
430 let mut deck = std::mem::take(
431 &mut ctx
432 .spec_runtime_mut::<FeralRuntime>()
433 .expect("Feral runtime registered")
434 .convoke_exceptional_deck,
435 );
436
437 if deck.is_empty() {
438 deck.extend([true, false]);
439
440 if proc_chance(ctx.rng(), EXCEPTIONAL_DECK_SHUFFLE_CHANCE) {
441 deck.swap(0, 1);
442 }
443 }
444
445 let result = deck.pop().unwrap_or(false);
446
447 ctx.spec_runtime_mut::<FeralRuntime>()
448 .expect("Feral runtime registered")
449 .convoke_exceptional_deck = deck;
450
451 result
452}
453
454pub(crate) fn convoke_cast_hook(ctx: &mut HookCtx<'_>) {
455 let max_ticks = usize::from(ctx.active_channel_ticks_remaining());
456
457 if max_ticks == 0 {
458 return;
459 }
460
461 let guidance = feral_config(ctx).ashamanes_guidance;
462 let offspec_min = if guidance {
463 GUIDED_OFFSPEC_MIN
464 } else {
465 STANDARD_OFFSPEC_MIN
466 };
467 let offspec_draw = wowlab_types::numeric::f64_to_usize_saturating_trunc(roll_range(
468 ctx.rng(),
469 offspec_min,
470 offspec_min + OFFSPEC_RANGE_WIDTH,
471 ));
472 let exceptional = convoke_exceptional_roll(ctx);
473 let offspec_count = offspec_draw.min(max_ticks.saturating_sub(usize::from(exceptional)));
474 let occupied = offspec_count + usize::from(exceptional);
475 let open = max_ticks.saturating_sub(occupied);
476 let bites = bounded_gaussian_count(
477 ctx.rng(),
478 if guidance {
479 GUIDED_BITE_MEAN
480 } else {
481 STANDARD_BITE_MEAN
482 },
483 if guidance {
484 GUIDED_BITE_STANDARD_DEVIATION
485 } else {
486 STANDARD_BITE_STANDARD_DEVIATION
487 },
488 open,
489 );
490 let mut casts = std::mem::take(
491 &mut ctx
492 .spec_runtime_mut::<FeralRuntime>()
493 .expect("Feral runtime registered")
494 .convoke_casts,
495 );
496
497 casts.clear();
498
499 if casts.capacity() < max_ticks {
500 casts.reserve(max_ticks);
501 }
502
503 casts.extend(std::iter::repeat_n(ConvokeCast::Offspec, offspec_count));
504
505 if exceptional {
506 casts.push(ConvokeCast::Exceptional);
507 }
508
509 casts.extend(std::iter::repeat_n(ConvokeCast::Main, bites));
510 casts.resize(max_ticks, ConvokeCast::Spec);
511 ctx.spec_runtime_mut::<FeralRuntime>()
512 .expect("Feral runtime registered")
513 .convoke_casts = casts;
514}
515
516fn convoke_weighted_action(
517 rng: &mut dyn FnMut() -> f64,
518 actions: &[(Option<wowlab_engine_combat::LocalSpellIdx>, f64)],
519) -> Option<wowlab_engine_combat::LocalSpellIdx> {
520 let total: f64 = actions.iter().map(|(_, weight)| weight).sum();
521 let roll = roll_range(rng, UNIT_INTERVAL_MIN, total);
522 let mut cumulative = 0.0;
523
524 for &(spell, weight) in actions {
525 cumulative += weight;
526
527 if roll < cumulative {
528 return spell;
529 }
530 }
531
532 None
533}
534
535pub(crate) fn convoke_tick_hook(ctx: &mut HookCtx<'_>) {
536 let cast = {
537 let mut casts = std::mem::take(
538 &mut ctx
539 .spec_runtime_mut::<FeralRuntime>()
540 .expect("Feral runtime registered")
541 .convoke_casts,
542 );
543
544 if casts.is_empty() {
545 return;
546 }
547
548 shuffle_pick(ctx.rng(), &mut casts, 1);
549 let cast = casts.swap_remove(0);
550
551 ctx.spec_runtime_mut::<FeralRuntime>()
552 .expect("Feral runtime registered")
553 .convoke_casts = casts;
554
555 cast
556 };
557 let spell_id = match cast {
558 ConvokeCast::Exceptional => Some(SPELL_FERAL_FRENZY),
559 ConvokeCast::Main => Some(SPELL_FEROCIOUS_BITE),
560 ConvokeCast::Offspec => convoke_weighted_action(ctx.rng(), &CONVOKE_OFFSPEC_ACTIONS),
561 ConvokeCast::Spec => convoke_weighted_action(ctx.rng(), &CONVOKE_SPEC_ACTIONS),
562 };
563 let Some(mut spell_id) = spell_id else {
564 return;
565 };
566
567 if spell_id == SPELL_MOONFIRE && ctx.is_aura_active(AURA_MOONFIRE) {
568 spell_id = SPELL_WRATH;
569 } else if spell_id == SPELL_RAKE && ctx.is_aura_active(AURA_RAKE) {
570 spell_id = SPELL_SHRED;
571 }
572
573 let _ = ctx.dispatch_free_action(spell_id, FreeActionSource::ChannelChild);
574}
575
576#[cfg(test)]
577#[path = "hooks/tests.rs"]
578mod tests;