Skip to main content

wowlab_engine_content/hooks/unholy_death_knight/
config.rs

1use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
2use wowlab_engine_ports::{EngineError, HandlerParams};
3
4use wowlab_types::{constants::HUNDRED, constants::MS_PER_SECOND, sim::SpellIdx};
5
6use crate::generated::specs::unholy_death_knight::{AURA, EFFECT, HERO, SET_BONUS, TALENT};
7
8const RUNE_OF_APOCALYPSE_ENCHANT_ID: u32 = 6_245;
9const UNDEATH_AURA_ID: u32 = 444_633;
10const RIDER_COUNT: usize = 4;
11const MOGRAINE_HEART_STRIKE_ID: u32 = 445_504;
12const NAZGRIM_SCOURGE_STRIKE_ID: u32 = 445_508;
13const TROLLBANE_OBLITERATE_ID: u32 = 445_507;
14const WHITEMANE_DEATH_COIL_ID: u32 = 445_513;
15const WHITEMANE_NPC_ID: u32 = 221_633;
16const RIDER_NPC_IDS: [u32; RIDER_COUNT] = [221_632, 221_634, 221_635, WHITEMANE_NPC_ID];
17const LESSER_GHOUL_NPC_ID: u32 = 237_409;
18const GARGOYLE_NPC_ID: u32 = 27_829;
19
20/// Gargoyle summon timings, expressed in milliseconds.
21#[derive(Clone, Copy, Debug)]
22pub(super) struct GargoyleTimings {
23    pub(super) army_spawn_delay: u32,
24    pub(super) duration: u32,
25    pub(super) strike_cast: u32,
26    pub(super) stat_inheritance: wowlab_engine_combat::PetStatInheritance,
27}
28
29#[derive(Clone, Copy, Debug)]
30pub(super) struct BlightburstConfig {
31    pub(super) damage_fraction: f64,
32    pub(super) duration_ms: u32,
33}
34
35#[derive(Clone, Copy, Debug)]
36pub(super) struct ForbiddenKnowledgeConfig {
37    pub(super) putrefy_fraction: f64,
38    pub(super) proc_chance: f64,
39}
40
41#[derive(Clone, Copy, Debug)]
42#[expect(
43    clippy::struct_excessive_bools,
44    reason = "these flags represent independent Rider of the Apocalypse talent selections"
45)]
46pub(super) struct RiderConfig {
47    pub(super) apocalypse_now: bool,
48    pub(super) apocalypse_duration_ms: u32,
49    pub(super) dnd_period_ms: u32,
50    pub(super) heart_strike_cooldown_ms: u32,
51    pub(super) icy_fury: bool,
52    pub(super) fury_extension_ms: u32,
53    pub(super) fury_max_extension_ms: u32,
54    pub(super) fury_rp_threshold: f64,
55    pub(super) let_terror_reign: bool,
56    pub(super) nazgrim_strike_cooldown_ms: u32,
57    pub(super) nazgrims_conquest: bool,
58    pub(super) obliterate_cooldown_ms: u32,
59    pub(super) random_duration_ms: u32,
60    pub(super) undeath_duration_ms: u32,
61    pub(super) whitemane_death_coil_cooldown_ms: u32,
62    pub(super) whitemane_attack_power_inheritance: f64,
63    pub(super) stat_inheritance: [wowlab_engine_combat::PetStatInheritance; RIDER_COUNT],
64    pub(super) whitemanes_famine: bool,
65}
66
67#[derive(Clone, Copy, Debug, Default)]
68pub(super) struct RiderRuntime {
69    pub(super) fury_pools: [f64; RIDER_COUNT],
70    pub(super) fury_rp_spent: [f64; RIDER_COUNT],
71    pub(super) last_random_rider: Option<u8>,
72}
73
74#[derive(Clone, Copy, Debug, Default)]
75pub(super) struct UnholyConfig {
76    pub(super) apocalypse_runeforge: bool,
77    pub(super) blightburst: Option<BlightburstConfig>,
78    pub(super) brittle_proc_chance: f64,
79    pub(super) eternal_agony_extension_ms: u32,
80    pub(super) forbidden_knowledge_ready_stacks: i32,
81    pub(super) forbidden_knowledge: Option<ForbiddenKnowledgeConfig>,
82    pub(super) gargoyle: Option<GargoyleTimings>,
83    pub(super) grave_mastery_pct: f64,
84    pub(super) lesser_ghoul_stat_inheritance: wowlab_engine_combat::PetStatInheritance,
85    pub(super) lord_of_the_dead_chance: f64,
86    pub(super) lord_of_the_dead_direct_mult: f64,
87    pub(super) lord_of_the_dead_periodic_mult: f64,
88    pub(super) mid2_2pc: bool,
89    pub(super) mid2_4pc_direct_mult: f64,
90    pub(super) mid2_4pc_periodic_mult: f64,
91    pub(super) mid2_4pc_threshold: f64,
92    pub(super) riders: Option<RiderConfig>,
93    pub(super) unholy_devotion_duration_ms: u32,
94}
95
96#[derive(Clone, Copy, Debug, Default)]
97pub(super) struct UnholyRuntime {
98    pub(super) riders: Option<RiderRuntime>,
99    pub(super) active_army_ghouls: u8,
100}
101
102// #t(fn: rust_cyclomatic_complexity) config construction mirrors independent talent and set gates
103pub(super) fn unholy_config(params: &HandlerParams<'_>) -> Result<UnholyConfig, EngineError> {
104    let forbidden_knowledge_ready_stacks = if params.talent_picked(TALENT::FORBIDDEN_KNOWLEDGE_2) {
105        stack_count(
106            params
107                .game_data
108                .require_effect_base_points(EFFECT::FORBIDDEN_KNOWLEDGE_READY_STACKS)?,
109        )?
110    } else {
111        0
112    };
113    let gargoyle = gargoyle_config(params)?;
114
115    Ok(UnholyConfig {
116        apocalypse_runeforge: params
117            .equipped_items
118            .iter()
119            .any(|item| item.enchant_id == Some(RUNE_OF_APOCALYPSE_ENCHANT_ID)),
120        blightburst: blightburst_config(params)?,
121        brittle_proc_chance: if params.talent_picked(TALENT::BRITTLE) {
122            params
123                .game_data
124                .proc_chance(SpellIdx::from_raw(TALENT::BRITTLE))
125                .unwrap_or_default()
126        } else {
127            0.0
128        },
129        eternal_agony_extension_ms: millisecond_count(
130            params
131                .game_data
132                .require_effect_base_points(EFFECT::ETERNAL_AGONY_EXTENSION_MS)?,
133            "Eternal Agony extension",
134        )?,
135        forbidden_knowledge_ready_stacks,
136        forbidden_knowledge: forbidden_knowledge_config(params),
137        gargoyle,
138        grave_mastery_pct: if params.talent_picked(TALENT::GRAVE_MASTERY) {
139            params.game_data.effect_base_points(EFFECT::GRAVE_MASTERY_PCT)
140        } else {
141            0.0
142        },
143        lesser_ghoul_stat_inheritance: wowlab_engine_combat::PetStatInheritance::new(
144            params.game_data.pet_attack_power_inheritance_mult(LESSER_GHOUL_NPC_ID),
145            params.game_data.pet_spell_power_inheritance_mult(LESSER_GHOUL_NPC_ID),
146        ),
147        lord_of_the_dead_chance: if params.talent_picked(TALENT::LORD_OF_THE_DEAD) {
148            params.game_data.effect_base_points(EFFECT::LORD_OF_THE_DEAD_CHANCE) / HUNDRED
149        } else {
150            0.0
151        },
152        lord_of_the_dead_direct_mult: if params.talent_picked(TALENT::LORD_OF_THE_DEAD) {
153            1.0 + params.game_data.effect_base_points(EFFECT::LORD_OF_THE_DEAD_DIRECT) / HUNDRED
154        } else {
155            1.0
156        },
157        lord_of_the_dead_periodic_mult: if params.talent_picked(TALENT::LORD_OF_THE_DEAD) {
158            1.0 + params.game_data.effect_base_points(EFFECT::LORD_OF_THE_DEAD_PERIODIC) / HUNDRED
159        } else {
160            1.0
161        },
162        mid2_2pc: params
163            .set_bonus_auras
164            .contains(&SET_BONUS::MID2_UNHOLY_2PC),
165        mid2_4pc_direct_mult: if params
166            .set_bonus_auras
167            .contains(&SET_BONUS::MID2_UNHOLY_4PC)
168        {
169            1.0 + params.game_data.effect_base_points(EFFECT::MID2_4PC_DIRECT_PCT) / HUNDRED
170        } else {
171            1.0
172        },
173        mid2_4pc_periodic_mult: if params
174            .set_bonus_auras
175            .contains(&SET_BONUS::MID2_UNHOLY_4PC)
176        {
177            1.0 + params.game_data.effect_base_points(EFFECT::MID2_4PC_PERIODIC_PCT) / HUNDRED
178        } else {
179            1.0
180        },
181        mid2_4pc_threshold: if params
182            .set_bonus_auras
183            .contains(&SET_BONUS::MID2_UNHOLY_4PC)
184        {
185            params.game_data.effect_base_points(EFFECT::MID2_4PC_EXECUTE_THRESHOLD) / HUNDRED
186        } else {
187            0.0
188        },
189        riders: rider_config(params)?,
190        unholy_devotion_duration_ms: if params.talent_picked(TALENT::UNHOLY_DEVOTION) {
191            params
192                .game_data
193                .require_aura_duration_ms(SpellIdx::from_raw(AURA::UNHOLY_DEVOTION))?
194        } else {
195            0
196        },
197    })
198}
199
200fn forbidden_knowledge_config(params: &HandlerParams<'_>) -> Option<ForbiddenKnowledgeConfig> {
201    params
202        .talent_picked(TALENT::FORBIDDEN_KNOWLEDGE_3)
203        .then(|| ForbiddenKnowledgeConfig {
204            putrefy_fraction: params.effect_base_points(EFFECT::FORBIDDEN_KNOWLEDGE_PUTREFY_PCT)
205                / HUNDRED,
206            proc_chance: params.effect_base_points(EFFECT::FORBIDDEN_KNOWLEDGE_PROC_PCT) / HUNDRED,
207        })
208}
209
210fn rider_config(params: &HandlerParams<'_>) -> Result<Option<RiderConfig>, EngineError> {
211    // #t(block: rust_imperative_talent_wiring) the champion talent owns the entire optional multi-field rider subsystem.
212    if !params.talent_picked(TALENT::RIDERS_CHAMPION) {
213        return Ok(None);
214    }
215
216    let cooldown_ms = |spell_id, label| -> Result<u32, EngineError> {
217        let seconds = params
218            .game_data
219            .require_cooldown_s(SpellIdx::from_raw(spell_id))?;
220
221        positive_millisecond_count(seconds * MS_PER_SECOND, label)
222    };
223
224    Ok(Some(RiderConfig {
225        apocalypse_now: params.talent_picked(TALENT::APOCALYPSE_NOW),
226        apocalypse_duration_ms: millisecond_count(
227            params
228                .game_data
229                .require_effect_base_points(EFFECT::APOCALYPSE_NOW_DURATION)?,
230            "Apocalypse Now rider duration",
231        )?,
232        dnd_period_ms: positive_millisecond_count(
233            params
234                .game_data
235                .effect_lookup(EFFECT::MOGRAINE_DND_PERIOD)
236                .period(),
237            "Mograine Death and Decay period",
238        )?,
239        heart_strike_cooldown_ms: cooldown_ms(
240            MOGRAINE_HEART_STRIKE_ID,
241            "Mograine Heart Strike cooldown",
242        )?,
243        icy_fury: params.talent_picked(TALENT::TROLLBANES_ICY_FURY),
244        fury_extension_ms: positive_millisecond_count(
245            params.effect_base_points(EFFECT::FURY_EXTENSION_SECONDS) * MS_PER_SECOND,
246            "Fury of the Horsemen extension",
247        )?,
248        fury_max_extension_ms: positive_millisecond_count(
249            params.effect_base_points(EFFECT::FURY_MAX_SECONDS) * MS_PER_SECOND,
250            "Fury of the Horsemen maximum extension",
251        )?,
252        fury_rp_threshold: if params.talent_picked(TALENT::FURY_OF_THE_HORSEMEN) {
253            params.effect_base_points(EFFECT::FURY_RP_THRESHOLD)
254        } else {
255            0.0
256        },
257        let_terror_reign: params.talent_picked(TALENT::LET_TERROR_REIGN),
258        nazgrim_strike_cooldown_ms: cooldown_ms(
259            NAZGRIM_SCOURGE_STRIKE_ID,
260            "Nazgrim Scourge Strike cooldown",
261        )?,
262        nazgrims_conquest: params.talent_picked(TALENT::NAZGRIMS_CONQUEST),
263        obliterate_cooldown_ms: cooldown_ms(
264            TROLLBANE_OBLITERATE_ID,
265            "Trollbane Obliterate cooldown",
266        )?,
267        random_duration_ms: params
268            .game_data
269            .require_aura_duration_ms(SpellIdx::from_raw(HERO::RIDERS::SPELL::RANDOM_SUMMON))?,
270        undeath_duration_ms: params
271            .game_data
272            .require_aura_duration_ms(SpellIdx::from_raw(UNDEATH_AURA_ID))?,
273        whitemane_death_coil_cooldown_ms: cooldown_ms(
274            WHITEMANE_DEATH_COIL_ID,
275            "Whitemane Death Coil cooldown",
276        )?,
277        whitemane_attack_power_inheritance: params
278            .game_data
279            .pet_attack_power_inheritance_mult(WHITEMANE_NPC_ID),
280        stat_inheritance: RIDER_NPC_IDS.map(|npc_id| {
281            wowlab_engine_combat::PetStatInheritance::new(
282                params.game_data.pet_attack_power_inheritance_mult(npc_id),
283                params.game_data.pet_spell_power_inheritance_mult(npc_id),
284            )
285        }),
286        whitemanes_famine: params.talent_picked(TALENT::WHITEMANES_FAMINE),
287    }))
288}
289
290fn blightburst_config(
291    params: &HandlerParams<'_>,
292) -> Result<Option<BlightburstConfig>, EngineError> {
293    // #t(block: rust_imperative_talent_wiring) the talent gates an optional config whose required DBC fields must fail construction when selected.
294    if !params.talent_picked(TALENT::BLIGHTBURST) {
295        return Ok(None);
296    }
297
298    let duration_ms = millisecond_count(
299        params
300            .game_data
301            .require_effect_base_points(EFFECT::BLIGHTBURST_DURATION)?,
302        "Blightburst duration",
303    )?;
304    let damage_fraction = params
305        .game_data
306        .require_effect_base_points(EFFECT::BLIGHTBURST_DAMAGE)?
307        / HUNDRED;
308
309    Ok(Some(BlightburstConfig {
310        damage_fraction,
311        duration_ms,
312    }))
313}
314
315fn gargoyle_config(params: &HandlerParams<'_>) -> Result<Option<GargoyleTimings>, EngineError> {
316    // #t(block: rust_imperative_talent_wiring) the talent gates an optional config whose required DBC timing fields must fail construction when selected.
317    if !params.talent_picked(TALENT::SUMMON_GARGOYLE) {
318        return Ok(None);
319    }
320
321    let army_ghouls = stack_count(
322        params
323            .game_data
324            .require_effect_base_points(EFFECT::ARMY_GHOUL_COUNT)?,
325    )?;
326    let summon_period_ms = params
327        .game_data
328        .effect_lookup(EFFECT::ARMY_GHOUL_COUNT)
329        .period();
330    let army_spawn_delay_ms = millisecond_count(
331        f64::from(army_ghouls.saturating_sub(1)) * summon_period_ms,
332        "Army of the Dead Gargoyle spawn delay",
333    )?;
334    let duration_ms = params
335        .game_data
336        .require_aura_duration_ms(SpellIdx::from_raw(
337            HERO::UNHOLY_PETS::SPELL::SUMMON_GARGOYLE,
338        ))?;
339    let strike_cast_ms = params.game_data.require_cast_time_ms(SpellIdx::from_raw(
340        HERO::UNHOLY_PETS::SPELL::GARGOYLE_STRIKE,
341    ))?;
342
343    Ok(Some(GargoyleTimings {
344        army_spawn_delay: army_spawn_delay_ms,
345        duration: duration_ms,
346        strike_cast: strike_cast_ms,
347        stat_inheritance: wowlab_engine_combat::PetStatInheritance::new(
348            params.game_data.pet_attack_power_inheritance_mult(GARGOYLE_NPC_ID),
349            params.game_data.pet_spell_power_inheritance_mult(GARGOYLE_NPC_ID),
350        ),
351    }))
352}
353
354fn stack_count(value: f64) -> Result<i32, EngineError> {
355    if !value.is_finite()
356        || value < 0.0
357        || value > f64::from(i32::MAX)
358        || value.fract().abs() > f64::EPSILON
359    {
360        return Err(EngineError::spec_construction(
361            "Forbidden Knowledge ready stacks must be a non-negative integer",
362        ));
363    }
364
365    Ok(wowlab_types::numeric::f64_to_i32_saturating_trunc(value))
366}
367
368fn millisecond_count(value: f64, label: &str) -> Result<u32, EngineError> {
369    if !value.is_finite()
370        || value < 0.0
371        || value > f64::from(u32::MAX)
372        || value.fract().abs() > f64::EPSILON
373    {
374        return Err(EngineError::spec_construction(format!(
375            "{label} must be a non-negative integer number of milliseconds"
376        )));
377    }
378
379    Ok(wowlab_types::numeric::f64_to_u32_saturating_trunc(value))
380}
381
382fn positive_millisecond_count(value: f64, label: &str) -> Result<u32, EngineError> {
383    let count = millisecond_count(value, label)?;
384
385    if count == 0 {
386        return Err(EngineError::spec_construction(format!(
387            "{label} must be greater than zero"
388        )));
389    }
390
391    Ok(count)
392}
393
394#[cfg(test)]
395mod tests {
396    use googletest::prelude::*;
397    use rstest::rstest;
398
399    use super::{millisecond_count, positive_millisecond_count, stack_count};
400
401    #[gtest]
402    #[rstest]
403    #[case::zero(0.0, Some(0))]
404    #[case::one(1.0, Some(1))]
405    #[case::fractional(1.5, None)]
406    #[case::negative(-1.0, None)]
407    #[case::not_finite(f64::NAN, None)]
408    fn ready_stack_data_requires_non_negative_integers(
409        #[case] value: f64,
410        #[case] expected: Option<i32>,
411    ) -> Result<()> {
412        verify_that!(stack_count(value).ok(), eq(expected))
413    }
414
415    #[gtest]
416    #[rstest]
417    #[case::zero(0.0, Some(0))]
418    #[case::army_delay(3_500.0, Some(3_500))]
419    #[case::fractional(3_500.5, None)]
420    #[case::negative(-1.0, None)]
421    #[case::not_finite(f64::NAN, None)]
422    fn timing_data_requires_non_negative_integer_milliseconds(
423        #[case] value: f64,
424        #[case] expected: Option<u32>,
425    ) -> Result<()> {
426        verify_that!(millisecond_count(value, "test timing").ok(), eq(expected))
427    }
428
429    #[gtest]
430    #[rstest]
431    #[case::zero(0.0, None)]
432    #[case::positive(1.0, Some(1))]
433    #[case::fractional(1.5, None)]
434    fn repeating_intervals_require_positive_integer_milliseconds(
435        #[case] value: f64,
436        #[case] expected: Option<u32>,
437    ) -> Result<()> {
438        verify_that!(
439            positive_millisecond_count(value, "test interval").ok(),
440            eq(expected)
441        )
442    }
443}