Skip to main content

wowlab_engine_content/hooks/unholy_death_knight/
riders.rs

1
2//! Rider of the Apocalypse actors and cross-actor spell overlays.
3
4use wowlab_engine_combat::DamageOps as _;
5use wowlab_engine_combat::ResourceOps as _;
6use wowlab_engine_combat::GuardianOps as _;
7use wowlab_engine_combat::AuraOps as _;
8use wowlab_engine_combat::{
9    DamageFlags, GuardianAbility, GuardianEvent, GuardianSpec, HookCtx,
10};
11use wowlab_engine_domain::constants::OFF_HAND_DAMAGE_MULT;
12use wowlab_engine_rng::proc_chance;
13
14use super::config::{RiderConfig, UnholyConfig, UnholyRuntime};
15use crate::generated::specs::unholy_death_knight::{
16    AURA_APOCALYPTIC_CONQUEST, AURA_DREAD_PLAGUE, AURA_MOGRAINES_MIGHT,
17    AURA_TROLLBANE_CHAINS, AURA_UNDEATH, AURA_VIRULENT_PLAGUE, EFFECT, HERO,
18    REPORTED_SPELL,
19};
20
21const RIDER_SWING_MS: u32 = 2_000;
22const RIDER_AUTO_ATTACK_MULTIPLIER: f64 = 0.75;
23const TROLLBANE_CHAINS_COOLDOWN_MS: u32 = 4_000;
24const RANDOM_RIDER_PROC_CHANCE: f64 = 0.20;
25const RIDER_DND_EXTENSION_MS: u32 = 1_000;
26const RIDER_COUNT: usize = 4;
27const TROLLBANE_INDEX: usize = 2;
28const WHITEMANE_INDEX: usize = 3;
29const TROLLBANE_CODE: u8 = 2;
30const WHITEMANE_CODE: u8 = 3;
31
32const MOGRAINE_NPC_ID: u32 = 221_632;
33const WHITEMANE_NPC_ID: u32 = 221_633;
34const NAZGRIM_NPC_ID: u32 = 221_634;
35const TROLLBANE_NPC_ID: u32 = 221_635;
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38#[repr(u8)]
39enum Rider {
40    Mograine,
41    Nazgrim,
42    Trollbane,
43    Whitemane,
44}
45
46impl Rider {
47    const ALL: [Self; 4] = [Self::Mograine, Self::Nazgrim, Self::Trollbane, Self::Whitemane];
48
49    const fn tag(self) -> u32 {
50        match self {
51            Self::Mograine => HERO::RIDERS::SPELL::MOGRAINE,
52            Self::Nazgrim => HERO::RIDERS::SPELL::NAZGRIM,
53            Self::Trollbane => HERO::RIDERS::SPELL::TROLLBANE,
54            Self::Whitemane => HERO::RIDERS::SPELL::WHITEMANE,
55        }
56    }
57
58    const fn npc_id(self) -> u32 {
59        match self {
60            Self::Mograine => MOGRAINE_NPC_ID,
61            Self::Nazgrim => NAZGRIM_NPC_ID,
62            Self::Trollbane => TROLLBANE_NPC_ID,
63            Self::Whitemane => WHITEMANE_NPC_ID,
64        }
65    }
66
67    const fn index(self) -> usize {
68        match self {
69            Self::Mograine => 0,
70            Self::Nazgrim => 1,
71            Self::Trollbane => TROLLBANE_INDEX,
72            Self::Whitemane => WHITEMANE_INDEX,
73        }
74    }
75
76    const fn code(self) -> u8 {
77        match self {
78            Self::Mograine => 0,
79            Self::Nazgrim => 1,
80            Self::Trollbane => TROLLBANE_CODE,
81            Self::Whitemane => WHITEMANE_CODE,
82        }
83    }
84}
85
86fn rider_config(ctx: &HookCtx<'_>) -> Option<RiderConfig> {
87    ctx.spec_config::<UnholyConfig>()?.riders
88}
89
90fn rider_auto_attack(ctx: &mut HookCtx<'_>, _event: GuardianEvent) {
91    ctx.deal_damage_ap(
92        REPORTED_SPELL::AUTO_ATTACK,
93        wowlab_engine_domain::damage::pet_swing_ap(RIDER_SWING_MS)
94            * RIDER_AUTO_ATTACK_MULTIPLIER,
95        DamageFlags::PHYSICAL,
96    );
97}
98
99fn mograine_off_hand(ctx: &mut HookCtx<'_>, _event: GuardianEvent) {
100    ctx.deal_damage_ap(
101        REPORTED_SPELL::AUTO_ATTACK,
102        wowlab_engine_domain::damage::pet_swing_ap(RIDER_SWING_MS)
103            * RIDER_AUTO_ATTACK_MULTIPLIER
104            * OFF_HAND_DAMAGE_MULT,
105        DamageFlags::PHYSICAL,
106    );
107}
108
109fn mograine_heart_strike(
110    ctx: &mut HookCtx<'_>,
111    _event: GuardianEvent,
112) {
113    ctx.deal_effect_damage_ap_with_geometry(
114        EFFECT::MOGRAINE_HEART_STRIKE,
115        EFFECT::MOGRAINE_HEART_STRIKE,
116        1.0,
117        DamageFlags::PHYSICAL,
118    );
119}
120
121fn mograine_death_and_decay(
122    ctx: &mut HookCtx<'_>,
123    _event: GuardianEvent,
124) {
125    ctx.apply_owner_aura(AURA_MOGRAINES_MIGHT.raw());
126    ctx.deal_effect_damage_ap_with_geometry(
127        EFFECT::MOGRAINE_DND,
128        EFFECT::MOGRAINE_DND,
129        1.0,
130        DamageFlags::empty(),
131    );
132    ctx.extend_aura(AURA_VIRULENT_PLAGUE.raw(), RIDER_DND_EXTENSION_MS);
133    ctx.extend_aura(AURA_DREAD_PLAGUE.raw(), RIDER_DND_EXTENSION_MS);
134}
135
136fn nazgrim_scourge_strike(
137    ctx: &mut HookCtx<'_>,
138    _event: GuardianEvent,
139) {
140    ctx.deal_damage_ap(
141        EFFECT::NAZGRIM_SCOURGE_STRIKE.0,
142        ctx.effect_ap_coefficient(EFFECT::NAZGRIM_SCOURGE_STRIKE),
143        DamageFlags::PHYSICAL,
144    );
145    ctx.deal_damage_ap(
146        REPORTED_SPELL::SCOURGE_STRIKE_SHADOW,
147        ctx.effect_ap_coefficient(EFFECT::NAZGRIM_SCOURGE_STRIKE_SHADOW),
148        DamageFlags::empty(),
149    );
150}
151
152fn trollbane_chains(ctx: &mut HookCtx<'_>, _event: GuardianEvent) {
153    ctx.apply_owner_aura(AURA_TROLLBANE_CHAINS.raw());
154}
155
156fn trollbane_obliterate(
157    ctx: &mut HookCtx<'_>,
158    _event: GuardianEvent,
159) {
160    ctx.deal_damage_ap(
161        REPORTED_SPELL::OBLITERATE,
162        ctx.effect_ap_coefficient(EFFECT::TROLLBANE_OBLITERATE),
163        DamageFlags::PHYSICAL,
164    );
165}
166
167fn whitemane_undeath(ctx: &mut HookCtx<'_>, _event: GuardianEvent) {
168    ctx.apply_owner_aura(AURA_UNDEATH.raw());
169}
170
171fn whitemane_death_coil(
172    ctx: &mut HookCtx<'_>,
173    _event: GuardianEvent,
174) {
175    ctx.deal_damage_ap(
176        REPORTED_SPELL::WHITEMANE_DEATH_COIL,
177        ctx.effect_ap_coefficient(EFFECT::WHITEMANE_DEATH_COIL),
178        DamageFlags::empty(),
179    );
180}
181
182fn rider_demise(ctx: &mut HookCtx<'_>, event: GuardianEvent) {
183    if event.tag == Rider::Mograine.tag() {
184        ctx.expire_owner_aura(AURA_MOGRAINES_MIGHT.raw());
185    } else if event.tag == Rider::Nazgrim.tag() {
186        ctx.expire_owner_aura(AURA_APOCALYPTIC_CONQUEST.raw());
187    }
188}
189
190fn rider_spec(rider: Rider, duration_ms: u32, config: RiderConfig) -> GuardianSpec {
191    let base = GuardianSpec::new_inheriting_owner(rider.tag(), duration_ms)
192        .npc_id(rider.npc_id())
193        .ability(
194            GuardianAbility::new(RIDER_SWING_MS, rider_auto_attack)
195                .first_action_delay(RIDER_SWING_MS)
196                .hasted_actions(),
197        )
198        .on_demise(rider_demise);
199
200    match rider {
201        Rider::Mograine => base
202            .ability(
203                GuardianAbility::new(RIDER_SWING_MS, mograine_off_hand)
204                    .first_action_delay(RIDER_SWING_MS)
205                    .hasted_actions(),
206            )
207            .ability(GuardianAbility::new(
208                config.heart_strike_cooldown_ms,
209                mograine_heart_strike,
210            ))
211            .ability(GuardianAbility::new(
212                config.dnd_period_ms,
213                mograine_death_and_decay,
214            )),
215        Rider::Nazgrim => base.ability(GuardianAbility::new(
216            config.nazgrim_strike_cooldown_ms,
217            nazgrim_scourge_strike,
218        )),
219        Rider::Trollbane => base
220            .ability(
221                GuardianAbility::new(TROLLBANE_CHAINS_COOLDOWN_MS, trollbane_chains)
222                    .hasted_actions(),
223            )
224            .ability(GuardianAbility::new(
225                config.obliterate_cooldown_ms,
226                trollbane_obliterate,
227            )),
228        Rider::Whitemane => base
229            .ability(GuardianAbility::new(
230                config.undeath_duration_ms,
231                whitemane_undeath,
232            ))
233            .ability(GuardianAbility::new(
234                config.whitemane_death_coil_cooldown_ms,
235                whitemane_death_coil,
236            )),
237    }
238}
239
240fn summon_rider(ctx: &mut HookCtx<'_>, rider: Rider, duration_ms: u32, extend_existing: bool) {
241    let Some(config) = rider_config(ctx) else {
242        return;
243    };
244
245    if ctx.active_guardian_count(rider.tag()) > 0 {
246        if extend_existing {
247            ctx.extend_guardians(rider.tag(), duration_ms);
248        }
249
250        return;
251    }
252
253    if rider == Rider::Mograine {
254        ctx.apply_aura(AURA_MOGRAINES_MIGHT.raw());
255    } else if rider == Rider::Nazgrim {
256        let stacks = wowlab_types::numeric::f64_to_i32_saturating_round(
257            ctx.effect_base_points(EFFECT::NAZGRIM_CONQUEST_BASE_PCT)
258                .max(0.0),
259        );
260
261        ctx.apply_aura_n(AURA_APOCALYPTIC_CONQUEST.raw(), stacks);
262    }
263
264    if let Some(runtime) = ctx
265        .spec_runtime_mut::<UnholyRuntime>()
266        .and_then(|runtime| runtime.riders.as_mut())
267    {
268        let index = rider.index();
269        // BOUNDS: Rider::index maps the four variants onto the four rider-state slots.
270
271        runtime.fury_pools[index] = 0.0;
272        // BOUNDS: Rider::index maps the four variants onto the four rider-state slots.
273        runtime.fury_rp_spent[index] = 0.0;
274    }
275
276    let _ = ctx.summon_guardian(rider_spec(rider, duration_ms, config));
277}
278
279pub(super) fn resource_spend(ctx: &mut HookCtx<'_>) {
280    let runes = wowlab_types::numeric::f64_to_i32_saturating_trunc(ctx.last_primary_spent().max(0.0).floor());
281    let Some(config) = rider_config(ctx) else {
282        return;
283    };
284
285    if runes > 0
286        && config.nazgrims_conquest
287        && ctx.active_guardian_count(Rider::Nazgrim.tag()) > 0
288    {
289        ctx.apply_aura_n(AURA_APOCALYPTIC_CONQUEST.raw(), runes);
290    }
291
292    extend_riders_from_rp(ctx, ctx.last_secondary_spent());
293}
294
295pub(super) fn extend_riders_from_rp(ctx: &mut HookCtx<'_>, rp_spent: f64) {
296    if rp_spent <= 0.0 {
297        return;
298    }
299
300    let active = Rider::ALL.map(|rider| ctx.active_guardian_count(rider.tag()) > 0);
301    let mut extend = [false; RIDER_COUNT];
302    let Some(config) = rider_config(ctx) else {
303        return;
304    };
305
306    if config.fury_rp_threshold <= 0.0 {
307        return;
308    }
309
310    let limit = config.fury_rp_threshold
311        * (f64::from(config.fury_max_extension_ms) / f64::from(config.fury_extension_ms));
312
313    if let Some(runtime) = ctx
314        .spec_runtime_mut::<UnholyRuntime>()
315        .and_then(|runtime| runtime.riders.as_mut())
316    {
317        for (index, is_active) in active.into_iter().enumerate() {
318            if !is_active {
319                continue;
320            }
321            // BOUNDS: index comes from enumerating Rider::ALL and all rider-state arrays have RIDER_COUNT slots.
322
323            extend[index] = accumulate_fury(
324                // BOUNDS: index comes from enumerating Rider::ALL and fury_pools has RIDER_COUNT slots.
325                &mut runtime.fury_pools[index],
326                // BOUNDS: index comes from enumerating Rider::ALL and fury_rp_spent has RIDER_COUNT slots.
327                &mut runtime.fury_rp_spent[index],
328                rp_spent,
329                config.fury_rp_threshold,
330                limit,
331            );
332        }
333    }
334
335    let extension_ms = config.fury_extension_ms;
336
337    for (index, should_extend) in extend.into_iter().enumerate() {
338        if should_extend {
339            // BOUNDS: index comes from enumerating an array with the same length as Rider::ALL.
340            ctx.extend_guardians(Rider::ALL[index].tag(), extension_ms);
341        }
342    }
343}
344
345const fn accumulate_fury(
346    pool: &mut f64,
347    spent: &mut f64,
348    amount: f64,
349    threshold: f64,
350    limit: f64,
351) -> bool {
352    if amount <= 0.0 || threshold <= 0.0 || *spent >= limit {
353        return false;
354    }
355
356    *spent += amount;
357    *pool += amount;
358
359    if *pool < threshold {
360        return false;
361    }
362
363    *pool -= threshold;
364
365    true
366}
367
368pub(super) fn summon_apocalypse_riders(ctx: &mut HookCtx<'_>) {
369    let Some(config) = rider_config(ctx) else {
370        return;
371    };
372
373    if !config.apocalypse_now {
374        return;
375    }
376
377    for rider in Rider::ALL {
378        summon_rider(ctx, rider, config.apocalypse_duration_ms, true);
379    }
380}
381
382pub(super) fn maybe_summon_random_rider(
383    ctx: &mut HookCtx<'_>,
384) {
385    let Some(config) = rider_config(ctx) else {
386        return;
387    };
388
389    if !proc_chance(ctx.rng(), RANDOM_RIDER_PROC_CHANCE) {
390        return;
391    }
392
393    let last_random_rider = ctx
394        .spec_runtime::<UnholyRuntime>()
395        .and_then(|runtime| runtime.riders)
396        .and_then(|runtime| runtime.last_random_rider);
397    let mut candidates = Rider::ALL;
398    let mut candidate_count = 0;
399
400    for rider in Rider::ALL {
401        if Some(rider.code()) != last_random_rider {
402            // BOUNDS: at most Rider::ALL.len() candidates are inserted into this same-sized array.
403            candidates[candidate_count] = rider;
404            candidate_count += 1;
405        }
406    }
407
408    let index = wowlab_types::numeric::f64_to_usize_saturating_trunc(
409        ctx.rng()() * wowlab_types::numeric::usize_to_f64(candidate_count),
410    )
411    .min(candidate_count - 1);
412    // BOUNDS: index is clamped to the populated candidate prefix.
413    let rider = candidates[index];
414
415    if let Some(runtime) = ctx
416        .spec_runtime_mut::<UnholyRuntime>()
417        .and_then(|runtime| runtime.riders.as_mut())
418    {
419        runtime.last_random_rider = Some(rider.code());
420    }
421
422    summon_rider(ctx, rider, config.random_duration_ms, false);
423}
424
425pub(super) fn trigger_icy_fury(ctx: &mut HookCtx<'_>) {
426    let Some(config) = rider_config(ctx) else {
427        return;
428    };
429
430    if !config.icy_fury
431        || ctx.active_guardian_count(Rider::Trollbane.tag()) == 0
432        || !ctx.is_aura_active(AURA_TROLLBANE_CHAINS.raw())
433    {
434        return;
435    }
436
437    ctx.expire_aura(AURA_TROLLBANE_CHAINS.raw());
438    ctx.deal_effect_damage_ap_with_geometry(
439        EFFECT::ICY_FURY_DAMAGE,
440        EFFECT::ICY_FURY_DAMAGE,
441        1.0,
442        DamageFlags::empty(),
443    );
444}
445
446pub(super) fn trigger_whitemane_famine(ctx: &mut HookCtx<'_>) {
447    let Some(config) = rider_config(ctx) else {
448        return;
449    };
450
451    if config.whitemanes_famine
452        && ctx.active_guardian_count(Rider::Whitemane.tag()) > 0
453        && ctx.is_aura_active(AURA_UNDEATH.raw())
454    {
455        ctx.add_aura_stack(AURA_UNDEATH.raw());
456    }
457}
458
459pub(super) fn trigger_let_terror_reign(
460    ctx: &mut HookCtx<'_>,
461) {
462    let Some(config) = rider_config(ctx) else {
463        return;
464    };
465
466    if !config.let_terror_reign || ctx.active_guardian_count(Rider::Whitemane.tag()) == 0 {
467        return;
468    }
469
470    let coefficient = ctx.effect_ap_coefficient(EFFECT::WHITEMANE_DEATH_COIL)
471        * ctx.effect_percent(EFFECT::LET_TERROR_REIGN_UNHOLY_PCT);
472
473    ctx.deal_pet_damage_ap_for_npc(
474        WHITEMANE_NPC_ID,
475        REPORTED_SPELL::DEATH_COIL_LET_TERROR_REIGN,
476        coefficient,
477    );
478}
479
480pub(crate) fn undeath_tick_hook(ctx: &mut HookCtx<'_>) {
481    ctx.add_aura_stack(AURA_UNDEATH.raw());
482}
483
484#[cfg(test)]
485mod tests {
486    use googletest::prelude::*;
487    use rstest::rstest;
488
489    use super::{accumulate_fury, Rider};
490
491    #[gtest]
492    #[rstest]
493    #[case(Rider::Mograine, 444_248, 221_632)]
494    #[case(Rider::Nazgrim, 444_252, 221_634)]
495    #[case(Rider::Trollbane, 444_254, 221_635)]
496    #[case(Rider::Whitemane, 444_251, 221_633)]
497    fn rider_identity_preserves_summon_and_creature_ids(
498        #[case] rider: Rider,
499        #[case] tag: u32,
500        #[case] npc_id: u32,
501    ) -> Result<()> {
502        verify_that!(rider.tag(), eq(tag))?;
503
504        verify_that!(rider.npc_id(), eq(npc_id))
505    }
506
507    #[gtest]
508    #[rstest]
509    #[case::below_threshold(20.0, 20.0, false)]
510    #[case::reaches_threshold(50.0, 0.0, true)]
511    #[case::carries_remainder(80.0, 30.0, true)]
512    fn fury_extends_once_per_threshold(
513        #[case] amount: f64,
514        #[case] expected_pool: f64,
515        #[case] expected_extension: bool,
516    ) -> Result<()> {
517        let (mut pool, mut spent) = (0.0, 0.0);
518
519        verify_that!(
520            accumulate_fury(&mut pool, &mut spent, amount, 50.0, 250.0),
521            eq(expected_extension)
522        )?;
523        verify_that!(pool, eq(expected_pool))?;
524
525        verify_that!(spent, eq(amount))
526    }
527
528    #[gtest]
529    fn fury_stops_at_its_extension_budget() -> Result<()> {
530        let (mut pool, mut spent) = (0.0, 250.0);
531
532        verify_that!(
533            accumulate_fury(&mut pool, &mut spent, 50.0, 50.0, 250.0),
534            eq(false)
535        )?;
536
537        verify_that!((pool, spent), eq((0.0, 250.0)))
538    }
539}