Skip to main content

wowlab_engine_content/hooks/unholy_death_knight/
hooks.rs

1use 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::GuardianOps as _;
6use wowlab_engine_combat::AuraOps as _;
7use wowlab_engine_combat::{
8    DamageFlags, GuardianAbility, GuardianEvent, GuardianSpec, HookCtx, ImpactEvent, LocalAuraIdx,
9    MasteryCtx, PetAbility, PetActionBar, PetResourceModel, SwingEvent, SwingHand,
10};
11use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
12use wowlab_engine_rng::proc_chance;
13use wowlab_types::{combat::DamageSchool, constants::HUNDRED, data::SpellLabel};
14
15use super::{
16    config::UnholyConfig,
17    riders::{
18        extend_riders_from_rp, maybe_summon_random_rider, summon_apocalypse_riders,
19        trigger_icy_fury, trigger_let_terror_reign, trigger_whitemane_famine,
20    },
21};
22use crate::generated::specs::unholy_death_knight::{
23    AURA_BRITTLE, AURA_COMMANDER_OF_THE_DEAD, AURA_DARK_TRANSFORMATION, AURA_DREAD_PLAGUE,
24    AURA_FESTERING_SCYTHE, AURA_FESTERING_SCYTHE_TT, AURA_FORBIDDEN_KNOWLEDGE,
25    AURA_GARGOYLE_ACTIVE, AURA_GARGOYLE_EMPOWERMENT, AURA_ICY_TALONS, AURA_INFECTED_CLAWS,
26    AURA_LESSER_GHOUL, AURA_LESSER_GHOUL_COUNTER, AURA_REAPING, AURA_RUNE_MASTERY,
27    AURA_RUNIC_CORRUPTION, AURA_SUDDEN_DOOM, AURA_UNHOLY_DEVOTION, AURA_VIRULENT_PLAGUE, EFFECT,
28    HERO, REPORTED_SPELL, SPELL_PUTREFY, TALENT,
29};
30
31/// Death Coil RP cost (spell 47541 power cost 300 divided by 10).
32const DEATH_COIL_RP_COST: i32 = 30;
33const RC_PER_RP_FACTOR: f64 = 0.0001;
34
35/// Permanent-ghoul attack power is 0.474 times owner attack power.
36const GHOUL_AP: f64 = 0.474;
37const LESSER_GHOUL_NPC_ID: u32 = 237_409;
38
39/// Lesser-ghoul attack power is 0.192975 times owner attack power.
40const LESSER_GHOUL_AP: f64 = 0.192_975;
41const PET_SWING_MS: u32 = 2_000;
42const LESSER_GHOUL_STEP_MS: u32 = 500;
43const LESSER_GHOUL_DURATION_MS: u32 = 8_000;
44const ARMY_GHOUL_DURATION_MS: u32 = 30_000;
45const LESSER_GHOUL_TAG: u32 = 275_430;
46const ARMY_GHOUL_TAG: u32 = 1_294_026;
47const MIDNIGHT_LESSER_GHOUL_AP_MULT: f64 = 1.10;
48
49const CLAW_BASE_MULT: f64 = 0.85;
50pub(super) const PERMANENT_GHOUL_NPC_ID: u32 = 26_125;
51const PERMANENT_GHOUL_ENERGY_REGEN: f64 = 10.0;
52const PERMANENT_GHOUL_ABILITY_COST: f64 = 40.0;
53const PERMANENT_GHOUL_GCD_MS: u32 = 1_500;
54const SWEEPING_CLAWS_GCD_MS: u32 = 1_000;
55const LESSER_GHOUL_CLAW_STEP_INTERVAL: u16 = 3;
56const LESSER_GHOUL_SWING_STEP_INTERVAL: u16 = 4;
57const LESSER_GHOUL_ENERGY_PER_STEP: f64 = 5.0;
58const LESSER_GHOUL_ABILITY_COST: f64 = 40.0;
59
60/// Army of the Dead summons eight ghouls at a 1.75 attack-power multiplier.
61const ARMY_GHOULS: usize = 8;
62const ARMY_GHOUL_AP_MULT: f64 = 1.75 * 0.8;
63
64const GARGOYLE_NPC_ID: u32 = 27_829;
65const GARGOYLE_INITIAL_STUN_MS: u32 = 2_800;
66
67/// Magus package averages 20 Shadow Bolts and nine Frostbolts per summon.
68const MAGUS_SHADOW_BOLTS: f64 = 20.0;
69const MAGUS_FROSTBOLTS: f64 = 9.0;
70
71/// Infected Claws: 15% per ghoul melee to apply the bleed (driver 1241792).
72const INFECTED_CLAWS_CHANCE: f64 = 0.15;
73
74#[must_use]
75pub(crate) fn mastery(ctx: &MasteryCtx<'_>) -> f64 {
76    if ctx.is_pet
77        || (ctx.has_school(DamageSchool::Shadow) && ctx.spell_has_label(SpellLabel::CLASS_SPELLS.0))
78    {
79        ctx.bonus(1)
80    } else {
81        1.0
82    }
83}
84
85const fn compounded_crit_damage_bonus(dreadblade_pct: f64, grave_mastery_pct: f64) -> f64 {
86    ((1.0 + dreadblade_pct / HUNDRED) * (1.0 + grave_mastery_pct / HUNDRED) - 1.0) * HUNDRED
87}
88
89#[must_use]
90pub(crate) fn crit_damage(ctx: &MasteryCtx<'_>) -> f64 {
91    if !ctx.is_pet {
92        return 0.0;
93    }
94
95    let data = &ctx.state.config.game_data;
96    let dreadblade_pct = ctx.points(EFFECT::DREADBLADE_PET_CRIT.1);
97    let grave_mastery_pct = ctx
98        .state
99        .spec_config::<UnholyConfig>()
100        .filter(|config| config.grave_mastery)
101        .map_or(0.0, |_| data.effect_base_points(EFFECT::GRAVE_MASTERY_PCT));
102
103    compounded_crit_damage_bonus(dreadblade_pct, grave_mastery_pct)
104}
105
106fn pet_package_mult(ctx: &HookCtx<'_>) -> f64 {
107    if ctx.is_aura_active(AURA_COMMANDER_OF_THE_DEAD.raw()) {
108        1.0 + ctx.effect_percent(EFFECT::COMMANDER_PCT)
109    } else {
110        1.0
111    }
112}
113
114const fn lesser_ghoul_ap_mult(tag: u32) -> f64 {
115    let summon_mult = if tag == ARMY_GHOUL_TAG {
116        ARMY_GHOUL_AP_MULT
117    } else {
118        1.0
119    };
120
121    LESSER_GHOUL_AP * MIDNIGHT_LESSER_GHOUL_AP_MULT * summon_mult
122}
123
124fn lesser_ghoul_claw_at(step: u16) -> bool {
125    if step % LESSER_GHOUL_CLAW_STEP_INTERVAL != 0 {
126        return false;
127    }
128
129    let mut energy = HUNDRED;
130    let mut claw = false;
131
132    for current in 0..=step {
133        if current > 0 {
134            energy = (energy + LESSER_GHOUL_ENERGY_PER_STEP).min(HUNDRED);
135        }
136
137        if current % LESSER_GHOUL_CLAW_STEP_INTERVAL == 0 && energy >= LESSER_GHOUL_ABILITY_COST {
138            energy -= LESSER_GHOUL_ABILITY_COST;
139            claw = current == step;
140        }
141    }
142
143    claw
144}
145
146fn roll_infected_claws(ctx: &mut HookCtx<'_>) {
147    if proc_chance(ctx.rng(), INFECTED_CLAWS_CHANCE) {
148        ctx.apply_owner_aura(AURA_INFECTED_CLAWS.raw());
149    }
150}
151
152fn lesser_ghoul_action(ctx: &mut HookCtx<'_>, event: GuardianEvent) {
153    let ap_mult = lesser_ghoul_ap_mult(event.tag) * pet_package_mult(ctx);
154
155    if event.action_index % LESSER_GHOUL_SWING_STEP_INTERVAL == 0 {
156        ctx.deal_damage_ap(
157            HERO::UNHOLY_PETS::SPELL::MELEE,
158            ap_mult * wowlab_engine_domain::damage::pet_swing_ap(PET_SWING_MS),
159            DamageFlags::PET,
160        );
161        roll_infected_claws(ctx);
162    }
163
164    if !lesser_ghoul_claw_at(event.action_index) {
165        return;
166    }
167
168    let transformed = ctx.is_aura_active(AURA_DARK_TRANSFORMATION.raw());
169    let effect = if transformed {
170        EFFECT::LESSER_SWEEPING
171    } else {
172        EFFECT::LESSER_CLAW
173    };
174
175    ctx.deal_damage_ap(
176        if transformed {
177            HERO::UNHOLY_PETS::SPELL::LESSER_SWEEPING_CLAWS
178        } else {
179            HERO::UNHOLY_PETS::SPELL::LESSER_CLAW
180        },
181        ap_mult * ctx.effect_ap_coefficient(effect),
182        DamageFlags::PET,
183    );
184    roll_infected_claws(ctx);
185}
186
187fn lesser_ghoul_demise(ctx: &mut HookCtx<'_>, event: GuardianEvent) {
188    ctx.consume_owner_aura_stack(AURA_LESSER_GHOUL_COUNTER.raw());
189    ctx.deal_damage_ap(
190        HERO::UNHOLY_PETS::SPELL::RUPTURED_VISCERA,
191        lesser_ghoul_ap_mult(event.tag)
192            * pet_package_mult(ctx)
193            * ctx.effect_ap_coefficient(EFFECT::RUPTURED_VISCERA),
194        DamageFlags::PET,
195    );
196}
197
198fn summon_lesser_ghoul(ctx: &mut HookCtx<'_>, army: bool) {
199    let (tag, duration_ms) = if army {
200        (ARMY_GHOUL_TAG, ARMY_GHOUL_DURATION_MS)
201    } else {
202        (LESSER_GHOUL_TAG, LESSER_GHOUL_DURATION_MS)
203    };
204
205    ctx.add_aura_stack(AURA_LESSER_GHOUL_COUNTER.raw());
206    let _ = ctx.summon_guardian(
207        GuardianSpec::new_inheriting_owner(tag, duration_ms)
208            .npc_id(LESSER_GHOUL_NPC_ID)
209            .ability(
210                GuardianAbility::new(LESSER_GHOUL_STEP_MS, lesser_ghoul_action).hasted_actions(),
211            )
212            .on_demise(lesser_ghoul_demise),
213    );
214}
215
216fn gargoyle_action(ctx: &mut HookCtx<'_>, event: GuardianEvent) {
217    if event.action_index == 0 {
218        return;
219    }
220
221    let coefficient = ctx.effect_ap_coefficient(EFFECT::GARGOYLE_STRIKE) * pet_package_mult(ctx);
222
223    ctx.deal_damage_ap(
224        HERO::UNHOLY_PETS::SPELL::GARGOYLE_STRIKE,
225        coefficient,
226        DamageFlags::PET,
227    );
228}
229
230fn summon_gargoyle(ctx: &mut HookCtx<'_>) {
231    let Some(config) = ctx
232        .spec_config::<UnholyConfig>()
233        .and_then(|config| config.gargoyle)
234    else {
235        return;
236    };
237
238    ctx.expire_aura(AURA_GARGOYLE_EMPOWERMENT.raw());
239    ctx.apply_aura(AURA_GARGOYLE_ACTIVE.raw());
240    let _ = ctx.summon_guardian(
241        GuardianSpec::new_inheriting_owner(
242            HERO::UNHOLY_PETS::SPELL::SUMMON_GARGOYLE,
243            config.duration,
244        )
245        .npc_id(GARGOYLE_NPC_ID)
246        .ability(
247            GuardianAbility::new(config.strike_cast, gargoyle_action)
248                .first_action_delay(GARGOYLE_INITIAL_STUN_MS)
249                .hasted_action_interval(),
250        ),
251    );
252}
253
254fn empower_gargoyle(ctx: &mut HookCtx<'_>, runic_power: i32) {
255    if !ctx.is_aura_active(AURA_GARGOYLE_ACTIVE.raw()) {
256        return;
257    }
258
259    for _ in 0..runic_power.max(0) {
260        ctx.add_aura_stack(AURA_GARGOYLE_EMPOWERMENT.raw());
261    }
262}
263
264pub(super) fn sudden_doom_fire(ctx: &mut HookCtx<'_>, _impact: ImpactEvent) {
265    let chance = ctx.effect_percent(EFFECT::SUDDEN_DOOM_PCT)
266        * (1.0 + ctx.effect_percent(EFFECT::HARBINGER_SD_PCT))
267        * (1.0 + ctx.effect_percent(EFFECT::EBON_FEVER_PCT));
268
269    if proc_chance(ctx.rng(), chance) {
270        ctx.apply_aura(AURA_SUDDEN_DOOM.raw());
271    }
272}
273
274fn runic_corruption(ctx: &mut HookCtx<'_>, rp_spent: f64) {
275    let chance = ctx.effect_base_points(EFFECT::RC_CHANCE_PCT) * RC_PER_RP_FACTOR * rp_spent;
276
277    if proc_chance(ctx.rng(), chance) {
278        ctx.apply_aura(AURA_RUNIC_CORRUPTION.raw());
279    }
280}
281
282pub(crate) fn festering_strike_hook(ctx: &mut HookCtx<'_>) {
283    let scythe = ctx.is_aura_active(AURA_FESTERING_SCYTHE.raw())
284        && !ctx.is_aura_active(AURA_FESTERING_SCYTHE_TT.raw());
285
286    if scythe {
287        ctx.consume_aura(AURA_FESTERING_SCYTHE.raw());
288        ctx.apply_aura(AURA_FESTERING_SCYTHE_TT.raw());
289        let coef = ctx.effect_ap_coefficient(EFFECT::SCYTHE_DAMAGE);
290
291        ctx.deal_damage_ap(
292            HERO::UNHOLY_PETS::SPELL::FESTERING_SCYTHE_STRIKE,
293            coef,
294            DamageFlags::PHYSICAL,
295        );
296    } else {
297        ctx.apply_aura(AURA_FESTERING_SCYTHE.raw());
298    }
299
300    let min = ctx.effect_base_points(EFFECT::FS_GHOULS_MIN);
301    let max = ctx.effect_base_points(EFFECT::FS_GHOULS_MAX) + 1.0;
302    let span = (max - min).max(0.0);
303    let readies = wowlab_types::numeric::f64_to_i32_saturating_trunc(min + ctx.rng()() * span);
304
305    ctx.apply_aura_n(AURA_LESSER_GHOUL.raw(), readies.max(1));
306
307    ctx.apply_aura(AURA_RUNE_MASTERY.raw());
308    maybe_summon_random_rider(ctx);
309}
310
311pub(crate) fn scourge_strike_hook(ctx: &mut HookCtx<'_>) {
312    let erupt = ctx.effect_percent(EFFECT::SS_ERUPT_PCT);
313
314    if ctx.is_aura_active(AURA_VIRULENT_PLAGUE.raw()) {
315        ctx.deal_profiled_damage_ap(
316            REPORTED_SPELL::VIRULENT_PLAGUE_ERUPT,
317            EFFECT::VP_TICK.0,
318            erupt * ctx.effect_ap_coefficient(EFFECT::VP_TICK),
319            DamageFlags::PERIODIC,
320        );
321    }
322
323    if ctx.is_aura_active(AURA_DREAD_PLAGUE.raw()) {
324        ctx.deal_profiled_damage_ap(
325            REPORTED_SPELL::DREAD_PLAGUE_ERUPT,
326            EFFECT::DP_TICK.0,
327            erupt * ctx.effect_ap_coefficient(EFFECT::DP_TICK),
328            DamageFlags::PERIODIC,
329        );
330    }
331
332    if ctx.consume_aura_stack(AURA_LESSER_GHOUL.raw()) {
333        summon_lesser_ghoul(ctx, false);
334    }
335
336    trigger_icy_fury(ctx);
337    trigger_whitemane_famine(ctx);
338    ctx.apply_aura(AURA_RUNE_MASTERY.raw());
339    maybe_summon_random_rider(ctx);
340}
341
342pub(crate) fn death_coil_hook(ctx: &mut HookCtx<'_>) {
343    let free = ctx.is_aura_active(AURA_SUDDEN_DOOM.raw());
344    let mut mult = 1.0;
345
346    if ctx.consume_aura(AURA_SUDDEN_DOOM.raw()) {
347        mult *= 1.0 + ctx.effect_percent(EFFECT::SD_DC_PCT);
348    }
349
350    if ctx.is_aura_active(AURA_FORBIDDEN_KNOWLEDGE.raw()) {
351        let shadow = ctx.effect_ap_coefficient(EFFECT::NECROTIC_SHADOW) * mult;
352        let strike = ctx.effect_ap_coefficient(EFFECT::NECROTIC_STRIKE) * mult;
353
354        ctx.deal_damage_ap(
355            HERO::UNHOLY_PETS::SPELL::NECROTIC_COIL,
356            shadow,
357            DamageFlags::empty(),
358        );
359        ctx.deal_damage_ap(
360            HERO::UNHOLY_PETS::SPELL::NECROTIC_COIL_SHADOWSTRIKE,
361            strike,
362            DamageFlags::empty(),
363        );
364    } else {
365        let coef = ctx.effect_ap_coefficient(EFFECT::DC_DAMAGE) * mult;
366
367        ctx.deal_damage_ap(EFFECT::DC_DAMAGE.0, coef, DamageFlags::empty());
368    }
369
370    if free {
371        summon_lesser_ghoul(ctx, false);
372    } else {
373        runic_corruption(ctx, f64::from(DEATH_COIL_RP_COST));
374    }
375
376    empower_gargoyle(ctx, DEATH_COIL_RP_COST);
377    extend_riders_from_rp(
378        ctx,
379        (f64::from(DEATH_COIL_RP_COST) - ctx.last_secondary_spent()).max(0.0),
380    );
381    trigger_let_terror_reign(ctx);
382    ctx.apply_aura(AURA_ICY_TALONS.raw());
383    extend_dark_transformation(ctx);
384}
385
386fn extend_dark_transformation(ctx: &mut HookCtx<'_>) {
387    let extension_ms = ctx
388        .spec_config::<UnholyConfig>()
389        .map_or(0, |config| config.eternal_agony_extension_ms);
390
391    if extension_ms > 0 && ctx.is_aura_active(AURA_DARK_TRANSFORMATION.raw()) {
392        ctx.extend_aura(AURA_DARK_TRANSFORMATION.raw(), extension_ms);
393    }
394}
395
396pub(crate) fn outbreak_hook(ctx: &mut HookCtx<'_>) {
397    ctx.apply_aura(AURA_RUNE_MASTERY.raw());
398    maybe_summon_random_rider(ctx);
399}
400
401pub(crate) fn dark_transformation_hook(ctx: &mut HookCtx<'_>) {
402    let energy = ctx.effect_base_points(EFFECT::DT_ENERGY_PCT);
403
404    ctx.gain_pet_resource(PERMANENT_GHOUL_NPC_ID, energy);
405    ctx.apply_aura(AURA_COMMANDER_OF_THE_DEAD.raw());
406    ctx.apply_aura(AURA_REAPING.raw());
407    magus_package(ctx);
408}
409
410fn magus_package(ctx: &mut HookCtx<'_>) {
411    let mult = pet_package_mult(ctx);
412    let sb = ctx.effect_ap_coefficient(EFFECT::MAGUS_SHADOW_BOLT) * MAGUS_SHADOW_BOLTS * mult;
413    let fb = ctx.effect_ap_coefficient(EFFECT::MAGUS_FROSTBOLT) * MAGUS_FROSTBOLTS * mult;
414
415    ctx.deal_pet_damage_ap(HERO::UNHOLY_PETS::SPELL::SHADOW_BOLT, sb);
416    ctx.deal_pet_damage_ap(HERO::UNHOLY_PETS::SPELL::FROSTBOLT, fb);
417}
418
419pub(crate) fn army_of_the_dead_hook(ctx: &mut HookCtx<'_>) {
420    maybe_summon_random_rider(ctx);
421
422    for _ in 0..ARMY_GHOULS {
423        summon_lesser_ghoul(ctx, true);
424    }
425
426    summon_apocalypse_riders(ctx);
427
428    if let Some(config) = ctx
429        .spec_config::<UnholyConfig>()
430        .and_then(|config| config.gargoyle)
431    {
432        ctx.schedule_hook(config.army_spawn_delay, summon_gargoyle);
433    }
434
435    magus_package(ctx);
436
437    ctx.apply_aura(AURA_FORBIDDEN_KNOWLEDGE.raw());
438    ctx.apply_aura(AURA_RUNE_MASTERY.raw());
439}
440
441pub(crate) fn soul_reaper_hook(ctx: &mut HookCtx<'_>) {
442    ctx.consume_aura_stack(AURA_REAPING.raw());
443
444    let summons = wowlab_types::numeric::f64_to_usize_saturating_round(
445        ctx.effect_base_points(EFFECT::SR_GHOULS).max(0.0),
446    );
447
448    for _ in 0..summons {
449        if !ctx.consume_aura_stack(AURA_LESSER_GHOUL.raw()) {
450            break;
451        }
452
453        trigger_putrefy_damage(ctx, 1.0);
454    }
455
456    ctx.apply_aura(AURA_RUNE_MASTERY.raw());
457    maybe_summon_random_rider(ctx);
458}
459
460pub(crate) fn putrefy_hook(ctx: &mut HookCtx<'_>) {
461    trigger_putrefy_damage(ctx, 1.0);
462
463    if ctx.spell_current_charges(SPELL_PUTREFY.raw()) > 1
464        && ctx.spend_spell_charge(SPELL_PUTREFY.raw())
465    {
466        trigger_putrefy_damage(ctx, 1.0);
467    }
468}
469
470pub(super) fn forbidden_knowledge_fire(ctx: &mut HookCtx<'_>, _impact: ImpactEvent) {
471    let Some(config) = ctx
472        .spec_config::<UnholyConfig>()
473        .and_then(|config| config.forbidden_knowledge)
474    else {
475        return;
476    };
477
478    if proc_chance(ctx.rng(), config.proc_chance) {
479        trigger_putrefy_damage(ctx, config.putrefy_fraction);
480    }
481}
482
483fn trigger_putrefy_damage(ctx: &mut HookCtx<'_>, damage_fraction: f64) {
484    let devotion_duration_ms = ctx
485        .spec_config::<UnholyConfig>()
486        .map_or(0, |config| config.unholy_devotion_duration_ms);
487
488    if devotion_duration_ms > 0 {
489        let duration_ms = wowlab_types::numeric::f64_to_u32_saturating_round(
490            f64::from(devotion_duration_ms) * damage_fraction,
491        );
492
493        ctx.apply_pet_aura_with_duration(AURA_UNHOLY_DEVOTION.raw(), duration_ms.max(1));
494    }
495
496    let st = ctx.effect_ap_coefficient(EFFECT::PUTREFY_ST) * damage_fraction;
497
498    ctx.deal_damage_ap(
499        HERO::UNHOLY_PETS::SPELL::PUTREFY_ST,
500        st,
501        DamageFlags::empty(),
502    );
503    ctx.deal_effect_damage_ap_with_geometry(
504        EFFECT::PUTREFY_AOE,
505        EFFECT::PUTREFY_AOE,
506        damage_fraction,
507        DamageFlags::empty(),
508    );
509    trigger_blightburst_disease(
510        ctx,
511        AURA_VIRULENT_PLAGUE.raw(),
512        EFFECT::VP_TICK,
513        REPORTED_SPELL::VIRULENT_PLAGUE_ERUPT,
514        damage_fraction,
515    );
516    trigger_blightburst_disease(
517        ctx,
518        AURA_DREAD_PLAGUE.raw(),
519        EFFECT::DP_TICK,
520        REPORTED_SPELL::DREAD_PLAGUE_ERUPT,
521        damage_fraction,
522    );
523    let ready_stacks = ctx
524        .spec_config::<UnholyConfig>()
525        .map_or(0, |config| config.forbidden_knowledge_ready_stacks);
526
527    if ready_stacks > 0 {
528        ctx.apply_aura_n(AURA_LESSER_GHOUL.raw(), ready_stacks);
529    }
530}
531
532fn trigger_blightburst_disease(
533    ctx: &mut HookCtx<'_>,
534    aura: u8,
535    periodic_effect: (u32, u8),
536    report_spell_id: u32,
537    source_fraction: f64,
538) {
539    let Some(config) = ctx
540        .spec_config::<UnholyConfig>()
541        .and_then(|config| config.blightburst)
542    else {
543        return;
544    };
545
546    if !ctx.is_aura_active(aura) {
547        ctx.apply_aura(aura);
548
549        return;
550    }
551
552    let base_period_ms = ctx
553        .game_data()
554        .period(
555            wowlab_types::sim::SpellIdx::from_raw(periodic_effect.0),
556            periodic_effect.1,
557        )
558        .max(1.0);
559    let duration_ms = wowlab_types::numeric::f64_to_u32_saturating_round(
560        f64::from(config.duration_ms) * source_fraction,
561    );
562    let tick_count = f64::from(duration_ms) * ctx.haste_multiplier() / base_period_ms;
563    let coefficient =
564        ctx.effect_ap_coefficient(periodic_effect) * tick_count * config.damage_fraction;
565
566    ctx.deal_profiled_damage_ap(
567        report_spell_id,
568        periodic_effect.0,
569        coefficient,
570        DamageFlags::PERIODIC,
571    );
572    ctx.extend_aura(aura, duration_ms);
573}
574
575/// Brittle rolls 374504's proc chance on every disease tick (sc_death_knight.cpp:7769-7775).
576pub(crate) fn disease_tick_hook(ctx: &mut HookCtx<'_>) {
577    let chance = ctx.selected_talent_proc_chance(TALENT::BRITTLE);
578
579    if proc_chance(ctx.rng(), chance) {
580        ctx.apply_aura(AURA_BRITTLE.raw());
581    }
582}
583
584fn permanent_ghoul_sweeping_claws(ctx: &mut HookCtx<'_>) {
585    let coefficient = GHOUL_AP
586        * ctx.effect_ap_coefficient(EFFECT::SWEEPING_MAIN)
587        * CLAW_BASE_MULT
588        * (1.0 + ctx.effect_percent(EFFECT::DT_PET_PCT));
589
590    ctx.deal_pet_damage_ap(HERO::UNHOLY_PETS::SPELL::SWEEPING_CLAWS, coefficient);
591    roll_infected_claws(ctx);
592}
593
594fn permanent_ghoul_claw(ctx: &mut HookCtx<'_>) {
595    let coefficient = GHOUL_AP * ctx.effect_ap_coefficient(EFFECT::CLAW) * CLAW_BASE_MULT;
596
597    ctx.deal_pet_damage_ap(HERO::UNHOLY_PETS::SPELL::CLAW, coefficient);
598    roll_infected_claws(ctx);
599}
600
601const PERMANENT_GHOUL_ABILITIES: [PetAbility; 2] = [
602    PetAbility::new(
603        PERMANENT_GHOUL_ABILITY_COST,
604        46.0,
605        permanent_ghoul_sweeping_claws,
606    )
607    .gcd_ms(SWEEPING_CLAWS_GCD_MS)
608    .while_owner_aura_active(LocalAuraIdx::new(AURA_DARK_TRANSFORMATION.raw())),
609    PetAbility::new(PERMANENT_GHOUL_ABILITY_COST, 71.0, permanent_ghoul_claw)
610        .while_owner_aura_inactive(LocalAuraIdx::new(AURA_DARK_TRANSFORMATION.raw())),
611];
612
613pub(super) const PERMANENT_GHOUL_ACTION_BAR: PetActionBar = PetActionBar::new(
614    PetResourceModel::new(HUNDRED).regen_per_second(PERMANENT_GHOUL_ENERGY_REGEN),
615    PERMANENT_GHOUL_GCD_MS,
616    &PERMANENT_GHOUL_ABILITIES,
617)
618.inherit_owner_stats();
619
620/// Permanent-ghoul white-swing effects. Active abilities use the pet action bar.
621pub(crate) fn pet_swing(ctx: &mut HookCtx<'_>, event: SwingEvent) {
622    let hand = event.hand;
623
624    if hand == SwingHand::OffHand {
625        return;
626    }
627
628    let dt_up = ctx.is_aura_active(AURA_DARK_TRANSFORMATION.raw());
629    let dt_mult = if dt_up {
630        1.0 + ctx.effect_percent(EFFECT::DT_PET_PCT)
631    } else {
632        1.0
633    };
634
635    if dt_up {
636        // The definition's swing excludes Dark Transformation's bonus portion.
637        let bonus =
638            GHOUL_AP * wowlab_engine_domain::damage::pet_swing_ap(PET_SWING_MS) * (dt_mult - 1.0);
639
640        ctx.deal_pet_damage_ap(HERO::UNHOLY_PETS::SPELL::MELEE, bonus);
641    }
642
643    roll_infected_claws(ctx);
644}
645
646#[cfg(test)]
647mod tests {
648    use googletest::prelude::*;
649    use rstest::rstest;
650
651    use super::{compounded_crit_damage_bonus, lesser_ghoul_claw_at};
652
653    #[gtest]
654    #[rstest]
655    #[case::neither(0.0, 0.0, 0.0)]
656    #[case::dreadblade_only(40.0, 0.0, 40.0)]
657    #[case::grave_mastery_only(0.0, 15.0, 15.0)]
658    #[case::both_compound(40.0, 15.0, 61.0)]
659    fn companion_crit_bonuses_compound(
660        #[case] dreadblade_pct: f64,
661        #[case] grave_mastery_pct: f64,
662        #[case] expected: f64,
663    ) -> Result<()> {
664        verify_that!(
665            compounded_crit_damage_bonus(dreadblade_pct, grave_mastery_pct),
666            near(expected, 1e-9)
667        )
668    }
669
670    #[gtest]
671    #[rstest]
672    #[case::spawn(0, true)]
673    #[case::second_gcd(3, true)]
674    #[case::third_gcd(6, true)]
675    #[case::energy_starved(9, false)]
676    #[case::first_regenerated_claw(12, true)]
677    #[case::still_starved(18, false)]
678    #[case::second_regenerated_claw(21, true)]
679    fn lesser_ghoul_claws_follow_energy_and_gcd(
680        #[case] step: u16,
681        #[case] expected: bool,
682    ) -> Result<()> {
683        verify_that!(lesser_ghoul_claw_at(step), eq(expected))
684    }
685}
686
687#[cfg(test)]
688#[path = "hooks/icy_talons_tests.rs"]
689mod icy_talons_tests;
690
691#[cfg(test)]
692#[path = "hooks/outbreak_tests.rs"]
693mod outbreak_tests;