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