wowlab_engine_content/hooks/havoc_demon_hunter/
hooks.rs1use wowlab_engine_combat::DamageOps as _;
2use wowlab_engine_combat::CooldownOps as _;
3use wowlab_engine_combat::ResourceOps as _;
4use wowlab_engine_combat::SchedulingOps as _;
5use wowlab_engine_combat::AuraOps as _;
6use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
7use wowlab_types::{constants::HUNDRED, constants::MS_PER_SECOND, sim::SpellIdx};
8
9use wowlab_engine_combat::{DamageFlags, HookCtx, MasteryCtx, SwingEvent};
10use wowlab_engine_rng::proc_chance;
11
12use crate::generated::specs::havoc_demon_hunter::{
13 AURA_CYCLE_OF_HATRED, AURA_DEMONIC_INTENSITY, AURA_EMPOWERED_EYE_BEAM, AURA_FURIOUS_GAZE,
14 AURA_METAMORPHOSIS, AURA_SERRATED_GLAIVE, AURA_STUDENT_OF_SUFFERING, EFFECT, SPELL,
15 SPELL_BLADE_DANCE, SPELL_EYE_BEAM, SPELL_THROW_GLAIVE,
16};
17
18use super::config::{DemonsurgeAbilities, HavocConfig, HavocRuntime, havoc_config};
19
20const META_BASE_MS: u32 = 20_000;
22
23const BLADE_DANCE_SLASHES: usize = 3;
25
26const DEMONSURGE_DELAY_MS: u32 = 450;
27const DEMONSURGE_DEATH_SWEEP_DELAY_MS: u32 = 700;
28const DEMONSURGE_MAX_STACKS: u8 = 4;
30
31#[must_use]
37pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
38 ctx.direct_or_periodic_affected_bonus_any(
39 &[EFFECT::MASTERY_DIRECT_BONUS.1],
40 &[EFFECT::MASTERY_PERIODIC_BONUS.1],
41 )
42}
43
44fn apply_serrated_glaive(ctx: &mut HookCtx<'_>) {
45 if havoc_config(ctx).serrated_glaive {
46 ctx.apply_aura(AURA_SERRATED_GLAIVE.raw());
47 }
48}
49
50pub(crate) fn chaos_strike_hook(ctx: &mut HookCtx<'_>) {
51 apply_serrated_glaive(ctx);
52 let refund_chance = ctx
53 .game_data()
54 .proc_chance(SpellIdx::from_raw(EFFECT::CHAOS_STRIKE_REFUND_PROC.0))
55 .unwrap_or_default();
56
57 if proc_chance(ctx.rng(), refund_chance) {
58 let refund = ctx
59 .game_data()
60 .effect_base_points(EFFECT::CHAOS_STRIKE_FURY_REFUND);
61
62 ctx.gain_resource(refund);
63 }
64}
65
66fn demonsurge_damage_hook(ctx: &mut HookCtx<'_>) {
67 let cfg = havoc_config(ctx);
68 let mult = ctx
69 .spec_runtime_mut::<HavocRuntime>()
70 .map_or(1.0, |runtime| {
71 let mult = 1.0 + cfg.demonsurge_stack_pct * f64::from(runtime.demonsurge_stacks);
72
73 runtime.demonsurge_stacks = runtime
74 .demonsurge_stacks
75 .saturating_add(1)
76 .min(DEMONSURGE_MAX_STACKS);
77
78 mult
79 });
80
81 if cfg.demonsurge {
82 ctx.deal_effect_damage_ap(EFFECT::DEMONSURGE_DAMAGE, mult, DamageFlags::empty());
83 }
84}
85
86fn trigger_demonsurge(ctx: &mut HookCtx<'_>, ability: DemonsurgeAbilities, delay_ms: u32) {
87 if !havoc_config(ctx).demonsurge {
88 return;
89 }
90
91 let available = ctx
92 .spec_runtime_mut::<HavocRuntime>()
93 .is_some_and(|runtime| {
94 if !runtime.demonsurge_available.contains(ability) {
95 return false;
96 }
97
98 runtime.demonsurge_available.remove(ability);
99
100 true
101 });
102
103 if available {
104 ctx.schedule_hook(delay_ms, demonsurge_damage_hook);
105 }
106}
107
108pub(crate) fn annihilation_hook(ctx: &mut HookCtx<'_>) {
110 chaos_strike_hook(ctx);
111 trigger_demonsurge(ctx, DemonsurgeAbilities::ANNIHILATION, DEMONSURGE_DELAY_MS);
112}
113
114pub(crate) fn throw_glaive_hook(ctx: &mut HookCtx<'_>) {
115 apply_serrated_glaive(ctx);
116
117 if havoc_config(ctx).furious_throws {
118 ctx.deal_effect_damage_ap(EFFECT::FURIOUS_THROWS_DAMAGE, 1.0, DamageFlags::PHYSICAL);
119 }
120}
121
122fn throw_glaive_cd_ready(ctx: &HookCtx<'_>) -> bool {
123 let now = ctx.now().as_secs_f64();
124
125 ctx.buf()
126 .cooldown(SpellIdx::from_raw(SPELL::THROW_GLAIVE))
127 .is_none_or(|cd| cd.ready_at <= now)
128}
129
130fn consume_throw_glaive_cd(ctx: &mut HookCtx<'_>) {
132 ctx.start_cooldown(SPELL_THROW_GLAIVE.raw());
133}
134
135fn screaming_brutality_on_blade_dance(ctx: &mut HookCtx<'_>, cfg: &HavocConfig) {
136 if !cfg.screaming_brutality {
137 return;
138 }
139
140 if throw_glaive_cd_ready(ctx) {
141 consume_throw_glaive_cd(ctx);
142 apply_serrated_glaive(ctx);
143 ctx.deal_effect_damage_ap(
144 EFFECT::THROW_GLAIVE_DAMAGE,
145 cfg.sb_full_pct,
146 DamageFlags::PHYSICAL,
147 );
148
149 if cfg.furious_throws {
150 ctx.deal_effect_damage_ap(
151 EFFECT::FURIOUS_THROWS_DAMAGE,
152 cfg.sb_full_pct,
153 DamageFlags::PHYSICAL,
154 );
155 }
156 }
157
158 for _ in 0..=BLADE_DANCE_SLASHES {
159 if proc_chance(ctx.rng(), cfg.sb_slash_chance) {
160 apply_serrated_glaive(ctx);
161 ctx.deal_effect_damage_ap(
162 EFFECT::THROW_GLAIVE_DAMAGE,
163 cfg.sb_slash_pct,
164 DamageFlags::PHYSICAL,
165 );
166
167 if cfg.furious_throws {
168 ctx.deal_effect_damage_ap(
169 EFFECT::FURIOUS_THROWS_DAMAGE,
170 cfg.sb_slash_pct,
171 DamageFlags::PHYSICAL,
172 );
173 }
174 }
175 }
176}
177
178pub(crate) fn blade_dance_hook(ctx: &mut HookCtx<'_>) {
179 let cfg = havoc_config(ctx);
180
181 screaming_brutality_on_blade_dance(ctx, &cfg);
182
183 if cfg.first_blood {
184 let mult = 1.0 + ctx.game_data().effect_base_points(EFFECT::FIRST_BLOOD_MULT) / HUNDRED;
185
186 for _ in 0..BLADE_DANCE_SLASHES {
187 ctx.deal_effect_damage_ap(EFFECT::FIRST_BLOOD_HIT, mult, DamageFlags::empty());
188 }
189
190 ctx.deal_effect_damage_ap(EFFECT::FIRST_BLOOD_FINAL, mult, DamageFlags::empty());
191 } else {
192 for _ in 0..BLADE_DANCE_SLASHES {
193 ctx.deal_effect_damage_ap(EFFECT::BLADE_DANCE_HIT, 1.0, DamageFlags::PHYSICAL);
194 }
195
196 ctx.deal_effect_damage_ap(EFFECT::BLADE_DANCE_FINAL, 1.0, DamageFlags::PHYSICAL);
197 }
198}
199
200pub(crate) fn death_sweep_hook(ctx: &mut HookCtx<'_>) {
201 let cfg = havoc_config(ctx);
202
203 screaming_brutality_on_blade_dance(ctx, &cfg);
204
205 if cfg.first_blood {
206 let mult = 1.0 + ctx.game_data().effect_base_points(EFFECT::FIRST_BLOOD_MULT) / HUNDRED;
207
208 for _ in 0..BLADE_DANCE_SLASHES {
209 ctx.deal_effect_damage_ap(
210 EFFECT::DEATH_SWEEP_FIRST_BLOOD_HIT,
211 mult,
212 DamageFlags::empty(),
213 );
214 }
215
216 ctx.deal_effect_damage_ap(
217 EFFECT::DEATH_SWEEP_FIRST_BLOOD_FINAL,
218 mult,
219 DamageFlags::empty(),
220 );
221 }
222
223 trigger_demonsurge(
224 ctx,
225 DemonsurgeAbilities::DEATH_SWEEP,
226 DEMONSURGE_DEATH_SWEEP_DELAY_MS,
227 );
228}
229
230fn eye_beam_channel_ms(ctx: &HookCtx<'_>) -> u32 {
231 let remaining_s = (ctx.buf().player().channel_end - ctx.now().as_secs_f64()).max(0.0);
232
233 wowlab_types::numeric::f64_to_u32_saturating_round(remaining_s * MS_PER_SECOND)
234}
235
236fn trigger_demonic(ctx: &mut HookCtx<'_>, cfg: &HavocConfig) {
237 if !cfg.demonic {
238 return;
239 }
240
241 if cfg.demonsurge {
242 if let Some(runtime) = ctx.spec_runtime_mut::<HavocRuntime>() {
243 runtime
244 .demonsurge_available
245 .insert(DemonsurgeAbilities::DEMONIC);
246 }
247 }
248
249 let total_ms = cfg.demonic_extension_ms + eye_beam_channel_ms(ctx);
250
251 if ctx.is_aura_active(AURA_METAMORPHOSIS.raw()) {
252 ctx.extend_aura(AURA_METAMORPHOSIS.raw(), total_ms);
253 } else {
254 ctx.apply_aura(AURA_METAMORPHOSIS.raw());
255 ctx.reduce_aura(
256 AURA_METAMORPHOSIS.raw(),
257 META_BASE_MS.saturating_sub(total_ms),
258 );
259 }
260}
261
262fn furious_gaze_apply_hook(ctx: &mut HookCtx<'_>) {
263 ctx.apply_aura(AURA_FURIOUS_GAZE.raw());
264}
265
266fn empowered_eye_beam_expire_hook(ctx: &mut HookCtx<'_>) {
267 ctx.expire_aura(AURA_EMPOWERED_EYE_BEAM.raw());
268}
269
270pub(crate) fn eye_beam_hook(ctx: &mut HookCtx<'_>) {
271 let cfg = havoc_config(ctx);
272
273 if cfg.student_of_suffering {
274 ctx.apply_aura(AURA_STUDENT_OF_SUFFERING.raw());
275 }
276
277 trigger_demonic(ctx, &cfg);
278
279 if cfg.cycle_of_hatred {
280 let stacks = ctx.aura_stacks(AURA_CYCLE_OF_HATRED.raw()).max(0);
282
283 if stacks > 0 {
284 ctx.reduce_cooldown(
285 SPELL_EYE_BEAM.raw(),
286 cfg.cycle_of_hatred_cdr_ms * wowlab_types::numeric::i32_to_u32_nonnegative(stacks),
287 );
288 }
289
290 ctx.apply_aura(AURA_CYCLE_OF_HATRED.raw());
291 }
292
293 if cfg.furious_gaze {
294 let channel_ms = eye_beam_channel_ms(ctx);
295
296 ctx.schedule_hook(channel_ms, furious_gaze_apply_hook);
297 }
298
299 if cfg.eternal_hunt && ctx.is_aura_active(AURA_EMPOWERED_EYE_BEAM.raw()) {
300 ctx.schedule_hook(
303 eye_beam_channel_ms(ctx).saturating_add(1),
304 empowered_eye_beam_expire_hook,
305 );
306 }
307}
308
309pub(crate) fn abyssal_gaze_hook(ctx: &mut HookCtx<'_>) {
310 eye_beam_hook(ctx);
311 trigger_demonsurge(ctx, DemonsurgeAbilities::ABYSSAL_GAZE, DEMONSURGE_DELAY_MS);
312}
313
314pub(crate) fn eye_beam_tick_hook(ctx: &mut HookCtx<'_>) {
315 let per_tick = havoc_config(ctx).blind_fury_per_tick;
316
317 if per_tick > 0.0 {
318 ctx.gain_resource(per_tick);
319 }
320
321 if ctx.is_aura_active(AURA_EMPOWERED_EYE_BEAM.raw()) {
322 ctx.deal_effect_damage_ap(EFFECT::EMPOWERED_EYE_BEAM_DAMAGE, 1.0, DamageFlags::empty());
325 } else {
326 ctx.deal_effect_damage_ap(EFFECT::EYE_BEAM_DAMAGE, 1.0, DamageFlags::empty());
327 }
328}
329
330pub(crate) fn the_hunt_hook(ctx: &mut HookCtx<'_>) {
331 if havoc_config(ctx).eternal_hunt {
332 ctx.apply_aura(AURA_EMPOWERED_EYE_BEAM.raw());
333 }
334}
335
336pub(crate) fn metamorphosis_hook(ctx: &mut HookCtx<'_>) {
337 let cfg = havoc_config(ctx);
338
339 if cfg.demonsurge {
340 if let Some(runtime) = ctx.spec_runtime_mut::<HavocRuntime>() {
341 runtime.demonsurge_available = DemonsurgeAbilities::ALL_HARDCAST;
342 runtime.demonsurge_stacks = 0;
343 }
344 }
345
346 if cfg.demonic_intensity {
347 ctx.apply_aura(AURA_DEMONIC_INTENSITY.raw());
348 }
349
350 if cfg.chaotic_transformation {
351 ctx.reset_cooldown(SPELL_EYE_BEAM.raw());
352 ctx.reset_cooldown(SPELL_BLADE_DANCE.raw());
353 }
354}
355
356pub(crate) fn consuming_fire_hook(ctx: &mut HookCtx<'_>) {
357 trigger_demonsurge(
358 ctx,
359 DemonsurgeAbilities::CONSUMING_FIRE,
360 DEMONSURGE_DELAY_MS,
361 );
362}
363
364pub(crate) fn swing_hook(ctx: &mut HookCtx<'_>, _event: SwingEvent) {
365 ctx.deal_effect_damage_ap(EFFECT::DEMON_BLADES_DAMAGE, 1.0, DamageFlags::empty());
366 let fury = ctx
367 .game_data()
368 .effect_base_points(EFFECT::DEMON_BLADES_FURY);
369
370 ctx.gain_resource(fury);
371}
372
373#[cfg(test)]
374mod demonic_presence_mastery_tests {
375 use googletest::prelude::*;
376
377 use super::*;
378 use wowlab_engine_ports::{CombatStats};
379use wowlab_engine_gamedata::{SpellProps};
380 use wowlab_types::sim::{EnemyIdx, SimTime};
381
382 use crate::generated::specs::havoc_demon_hunter::REPORTED_SPELL;
383
384 const DEMON_HUNTER_SPELL_FAMILY: i32 = 107;
385 const DEMONIC_PRESENCE_PCT: f64 = 34.07;
386 const MASTERY_TEST_ROTATION: &str = r#"{"version":1,"name":"presence","variables":{},"actions":[{"type":"wait","seconds":1.0}],"lists":{}}"#;
387
388 #[gtest]
391 fn demonic_presence_applies_only_to_its_dbc_affect_list() -> Result<()> {
392 let presence = SpellIdx::from_raw(EFFECT::MASTERY_DIRECT_BONUS.0);
393 let direct = EFFECT::MASTERY_DIRECT_BONUS.1;
394 let periodic = EFFECT::MASTERY_PERIODIC_BONUS.1;
395 let mut data = wowlab_engine_gamedata::ResolvedGameData::builder();
396
397 data.insert_spell_props(
398 presence,
399 SpellProps {
400 mastery_affects_points: true,
401 ..SpellProps::default()
402 },
403 );
404 data.insert_sp_coef(presence, direct, 1.0);
405 data.insert_sp_coef(presence, periodic, 1.0);
406 data.insert_spell_class_flags(presence, DEMON_HUNTER_SPELL_FAMILY, [0, 0, 0, 0]);
407 data.insert_effect_class_mask(presence, direct, [1, 0, 0, 0]);
408 data.insert_effect_class_mask(presence, periodic, [1, 0, 0, 0]);
409
410 for (spells, mask) in [
411 (
412 [
413 REPORTED_SPELL::CHAOS_STRIKE_DAMAGE_1,
414 REPORTED_SPELL::SOULSCAR,
415 ],
416 [1, 0, 0, 0],
417 ),
418 (
419 [
420 REPORTED_SPELL::FELBLADE_DAMAGE,
421 REPORTED_SPELL::IMMOLATION_AURA_DAMAGE,
422 ],
423 [2, 0, 0, 0],
424 ),
425 ] {
426 for spell in spells {
427 data.insert_spell_class_flags(
428 SpellIdx::from_raw(spell),
429 DEMON_HUNTER_SPELL_FAMILY,
430 mask,
431 );
432 }
433 }
434
435 let built = crate::test_combat_builder(CombatStats::default())
436 .game_data(data.build())
437 .mastery_spell(EFFECT::MASTERY_DIRECT_BONUS.0)
438 .mastery_hook(mastery)
439 .spell("felblade", SPELL::FELBLADE, Ok)
440 .build(crate::test_support::rotation_from_json(MASTERY_TEST_ROTATION))
441 .or_fail()?;
442 let bonus = |spell_id: u32, is_periodic: bool| {
443 mastery(&MasteryCtx {
444 state: &built.state,
445 buf: &built.buffer,
446 spell_id,
447 school: wowlab_types::combat::DamageSchool::Chaos,
448 mastery: DEMONIC_PRESENCE_PCT,
449 player_crit_pct: 0.0,
450 mastery_spell: presence,
451 is_periodic,
452 is_pet: false,
453 source: wowlab_types::sim::ActorId::Player,
454 target: Some(EnemyIdx::PRIMARY),
455 now: SimTime::ZERO,
456 })
457 };
458 let listed = 1.0 + DEMONIC_PRESENCE_PCT / HUNDRED;
459
460 verify_that!(
461 bonus(REPORTED_SPELL::CHAOS_STRIKE_DAMAGE_1, false),
462 near(listed, 1e-12)
463 )?;
464 verify_that!(bonus(REPORTED_SPELL::SOULSCAR, true), near(listed, 1e-12))?;
465
466 for spell in [
467 REPORTED_SPELL::FELBLADE_DAMAGE,
468 REPORTED_SPELL::IMMOLATION_AURA_DAMAGE,
469 ] {
470 verify_that!(bonus(spell, false), near(1.0, 1e-12))?;
471 }
472
473 Ok(())
474 }
475}
476
477#[cfg(test)]
478#[path = "hooks/serrated_glaive_tests.rs"]
479mod serrated_glaive_tests;