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, 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;
15
16/// Gargoyle summon timings, expressed in milliseconds.
17#[derive(Clone, Copy, Debug)]
18pub(super) struct GargoyleTimings {
19    pub(super) army_spawn_delay: u32,
20    pub(super) duration: u32,
21    pub(super) strike_cast: u32,
22}
23
24#[derive(Clone, Copy, Debug)]
25pub(super) struct BlightburstConfig {
26    pub(super) damage_fraction: f64,
27    pub(super) duration_ms: u32,
28}
29
30#[derive(Clone, Copy, Debug)]
31pub(super) struct ForbiddenKnowledgeConfig {
32    pub(super) putrefy_fraction: f64,
33    pub(super) proc_chance: f64,
34}
35
36#[derive(Clone, Copy, Debug)]
37#[expect(
38    clippy::struct_excessive_bools,
39    reason = "these flags represent independent Rider of the Apocalypse talent selections"
40)]
41pub(super) struct RiderConfig {
42    pub(super) apocalypse_now: bool,
43    pub(super) apocalypse_duration_ms: u32,
44    pub(super) dnd_period_ms: u32,
45    pub(super) heart_strike_cooldown_ms: u32,
46    pub(super) icy_fury: bool,
47    pub(super) fury_extension_ms: u32,
48    pub(super) fury_max_extension_ms: u32,
49    pub(super) fury_rp_threshold: f64,
50    pub(super) let_terror_reign: bool,
51    pub(super) nazgrim_strike_cooldown_ms: u32,
52    pub(super) nazgrims_conquest: bool,
53    pub(super) obliterate_cooldown_ms: u32,
54    pub(super) random_duration_ms: u32,
55    pub(super) undeath_duration_ms: u32,
56    pub(super) whitemane_death_coil_cooldown_ms: u32,
57    pub(super) whitemanes_famine: bool,
58}
59
60#[derive(Clone, Copy, Debug, Default)]
61pub(super) struct RiderRuntime {
62    pub(super) fury_pools: [f64; RIDER_COUNT],
63    pub(super) fury_rp_spent: [f64; RIDER_COUNT],
64    pub(super) last_random_rider: Option<u8>,
65}
66
67#[derive(Clone, Copy, Debug, Default)]
68pub(super) struct UnholyConfig {
69    pub(super) apocalypse_runeforge: bool,
70    pub(super) blightburst: Option<BlightburstConfig>,
71    pub(super) eternal_agony_extension_ms: u32,
72    pub(super) forbidden_knowledge_ready_stacks: i32,
73    pub(super) forbidden_knowledge: Option<ForbiddenKnowledgeConfig>,
74    pub(super) gargoyle: Option<GargoyleTimings>,
75    pub(super) grave_mastery: bool,
76    pub(super) riders: Option<RiderConfig>,
77    pub(super) unholy_devotion_duration_ms: u32,
78}
79
80#[derive(Clone, Copy, Debug, Default)]
81pub(super) struct UnholyRuntime {
82    pub(super) riders: Option<RiderRuntime>,
83}
84
85pub(super) fn unholy_config(params: &HandlerParams<'_>) -> Result<UnholyConfig, EngineError> {
86    let forbidden_knowledge_ready_stacks = if params.talent_picked(TALENT::FORBIDDEN_KNOWLEDGE_2) {
87        stack_count(
88            params
89                .game_data
90                .require_effect_base_points(EFFECT::FORBIDDEN_KNOWLEDGE_READY_STACKS)?,
91        )?
92    } else {
93        0
94    };
95    let gargoyle = gargoyle_config(params)?;
96
97    Ok(UnholyConfig {
98        apocalypse_runeforge: params
99            .equipped_items
100            .iter()
101            .any(|item| item.enchant_id == Some(RUNE_OF_APOCALYPSE_ENCHANT_ID)),
102        blightburst: blightburst_config(params)?,
103        eternal_agony_extension_ms: millisecond_count(
104            params
105                .game_data
106                .require_effect_base_points(EFFECT::ETERNAL_AGONY_EXTENSION_MS)?,
107            "Eternal Agony extension",
108        )?,
109        forbidden_knowledge_ready_stacks,
110        forbidden_knowledge: forbidden_knowledge_config(params),
111        gargoyle,
112        grave_mastery: params.talent_picked(TALENT::GRAVE_MASTERY),
113        riders: rider_config(params)?,
114        unholy_devotion_duration_ms: if params.talent_picked(TALENT::UNHOLY_DEVOTION) {
115            params
116                .game_data
117                .require_aura_duration_ms(SpellIdx::from_raw(AURA::UNHOLY_DEVOTION))?
118        } else {
119            0
120        },
121    })
122}
123
124fn forbidden_knowledge_config(params: &HandlerParams<'_>) -> Option<ForbiddenKnowledgeConfig> {
125    params
126        .talent_picked(TALENT::FORBIDDEN_KNOWLEDGE_3)
127        .then(|| ForbiddenKnowledgeConfig {
128            putrefy_fraction: params.effect_base_points(EFFECT::FORBIDDEN_KNOWLEDGE_PUTREFY_PCT)
129                / HUNDRED,
130            proc_chance: params.effect_base_points(EFFECT::FORBIDDEN_KNOWLEDGE_PROC_PCT) / HUNDRED,
131        })
132}
133
134fn rider_config(params: &HandlerParams<'_>) -> Result<Option<RiderConfig>, EngineError> {
135    // #t(block: rust_imperative_talent_wiring) the champion talent owns the entire optional multi-field rider subsystem.
136    if !params.talent_picked(TALENT::RIDERS_CHAMPION) {
137        return Ok(None);
138    }
139
140    let cooldown_ms = |spell_id, label| -> Result<u32, EngineError> {
141        let seconds = params
142            .game_data
143            .require_cooldown_s(SpellIdx::from_raw(spell_id))?;
144
145        positive_millisecond_count(seconds * MS_PER_SECOND, label)
146    };
147
148    Ok(Some(RiderConfig {
149        apocalypse_now: params.talent_picked(TALENT::APOCALYPSE_NOW),
150        apocalypse_duration_ms: millisecond_count(
151            params
152                .game_data
153                .require_effect_base_points(EFFECT::APOCALYPSE_NOW_DURATION)?,
154            "Apocalypse Now rider duration",
155        )?,
156        dnd_period_ms: positive_millisecond_count(
157            params
158                .game_data
159                .effect_lookup(EFFECT::MOGRAINE_DND_PERIOD)
160                .period(),
161            "Mograine Death and Decay period",
162        )?,
163        heart_strike_cooldown_ms: cooldown_ms(
164            MOGRAINE_HEART_STRIKE_ID,
165            "Mograine Heart Strike cooldown",
166        )?,
167        icy_fury: params.talent_picked(TALENT::TROLLBANES_ICY_FURY),
168        fury_extension_ms: positive_millisecond_count(
169            params.effect_base_points(EFFECT::FURY_EXTENSION_SECONDS) * MS_PER_SECOND,
170            "Fury of the Horsemen extension",
171        )?,
172        fury_max_extension_ms: positive_millisecond_count(
173            params.effect_base_points(EFFECT::FURY_MAX_SECONDS) * MS_PER_SECOND,
174            "Fury of the Horsemen maximum extension",
175        )?,
176        fury_rp_threshold: if params.talent_picked(TALENT::FURY_OF_THE_HORSEMEN) {
177            params.effect_base_points(EFFECT::FURY_RP_THRESHOLD)
178        } else {
179            0.0
180        },
181        let_terror_reign: params.talent_picked(TALENT::LET_TERROR_REIGN),
182        nazgrim_strike_cooldown_ms: cooldown_ms(
183            NAZGRIM_SCOURGE_STRIKE_ID,
184            "Nazgrim Scourge Strike cooldown",
185        )?,
186        nazgrims_conquest: params.talent_picked(TALENT::NAZGRIMS_CONQUEST),
187        obliterate_cooldown_ms: cooldown_ms(
188            TROLLBANE_OBLITERATE_ID,
189            "Trollbane Obliterate cooldown",
190        )?,
191        random_duration_ms: params
192            .game_data
193            .require_aura_duration_ms(SpellIdx::from_raw(HERO::RIDERS::SPELL::RANDOM_SUMMON))?,
194        undeath_duration_ms: params
195            .game_data
196            .require_aura_duration_ms(SpellIdx::from_raw(UNDEATH_AURA_ID))?,
197        whitemane_death_coil_cooldown_ms: cooldown_ms(
198            WHITEMANE_DEATH_COIL_ID,
199            "Whitemane Death Coil cooldown",
200        )?,
201        whitemanes_famine: params.talent_picked(TALENT::WHITEMANES_FAMINE),
202    }))
203}
204
205fn blightburst_config(
206    params: &HandlerParams<'_>,
207) -> Result<Option<BlightburstConfig>, EngineError> {
208    // #t(block: rust_imperative_talent_wiring) the talent gates an optional config whose required DBC fields must fail construction when selected.
209    if !params.talent_picked(TALENT::BLIGHTBURST) {
210        return Ok(None);
211    }
212
213    let duration_ms = millisecond_count(
214        params
215            .game_data
216            .require_effect_base_points(EFFECT::BLIGHTBURST_DURATION)?,
217        "Blightburst duration",
218    )?;
219    let damage_fraction = params
220        .game_data
221        .require_effect_base_points(EFFECT::BLIGHTBURST_DAMAGE)?
222        / HUNDRED;
223
224    Ok(Some(BlightburstConfig {
225        damage_fraction,
226        duration_ms,
227    }))
228}
229
230fn gargoyle_config(params: &HandlerParams<'_>) -> Result<Option<GargoyleTimings>, EngineError> {
231    // #t(block: rust_imperative_talent_wiring) the talent gates an optional config whose required DBC timing fields must fail construction when selected.
232    if !params.talent_picked(TALENT::SUMMON_GARGOYLE) {
233        return Ok(None);
234    }
235
236    let army_ghouls = stack_count(
237        params
238            .game_data
239            .require_effect_base_points(EFFECT::ARMY_GHOUL_COUNT)?,
240    )?;
241    let summon_period_ms = params
242        .game_data
243        .effect_lookup(EFFECT::ARMY_GHOUL_COUNT)
244        .period();
245    let army_spawn_delay_ms = millisecond_count(
246        f64::from(army_ghouls.saturating_sub(1)) * summon_period_ms,
247        "Army of the Dead Gargoyle spawn delay",
248    )?;
249    let duration_ms = params
250        .game_data
251        .require_aura_duration_ms(SpellIdx::from_raw(
252            HERO::UNHOLY_PETS::SPELL::SUMMON_GARGOYLE,
253        ))?;
254    let strike_cast_ms = params.game_data.require_cast_time_ms(SpellIdx::from_raw(
255        HERO::UNHOLY_PETS::SPELL::GARGOYLE_STRIKE,
256    ))?;
257
258    Ok(Some(GargoyleTimings {
259        army_spawn_delay: army_spawn_delay_ms,
260        duration: duration_ms,
261        strike_cast: strike_cast_ms,
262    }))
263}
264
265fn stack_count(value: f64) -> Result<i32, EngineError> {
266    if !value.is_finite()
267        || value < 0.0
268        || value > f64::from(i32::MAX)
269        || value.fract().abs() > f64::EPSILON
270    {
271        return Err(EngineError::spec_construction(
272            "Forbidden Knowledge ready stacks must be a non-negative integer",
273        ));
274    }
275
276    Ok(wowlab_types::numeric::f64_to_i32_saturating_trunc(value))
277}
278
279fn millisecond_count(value: f64, label: &str) -> Result<u32, EngineError> {
280    if !value.is_finite()
281        || value < 0.0
282        || value > f64::from(u32::MAX)
283        || value.fract().abs() > f64::EPSILON
284    {
285        return Err(EngineError::spec_construction(format!(
286            "{label} must be a non-negative integer number of milliseconds"
287        )));
288    }
289
290    Ok(wowlab_types::numeric::f64_to_u32_saturating_trunc(value))
291}
292
293fn positive_millisecond_count(value: f64, label: &str) -> Result<u32, EngineError> {
294    let count = millisecond_count(value, label)?;
295
296    if count == 0 {
297        return Err(EngineError::spec_construction(format!(
298            "{label} must be greater than zero"
299        )));
300    }
301
302    Ok(count)
303}
304
305#[cfg(test)]
306mod tests {
307    use googletest::prelude::*;
308    use rstest::rstest;
309
310    use super::{millisecond_count, positive_millisecond_count, stack_count};
311
312    #[gtest]
313    #[rstest]
314    #[case::zero(0.0, Some(0))]
315    #[case::one(1.0, Some(1))]
316    #[case::fractional(1.5, None)]
317    #[case::negative(-1.0, None)]
318    #[case::not_finite(f64::NAN, None)]
319    fn ready_stack_data_requires_non_negative_integers(
320        #[case] value: f64,
321        #[case] expected: Option<i32>,
322    ) -> Result<()> {
323        verify_that!(stack_count(value).ok(), eq(expected))
324    }
325
326    #[gtest]
327    #[rstest]
328    #[case::zero(0.0, Some(0))]
329    #[case::army_delay(3_500.0, Some(3_500))]
330    #[case::fractional(3_500.5, None)]
331    #[case::negative(-1.0, None)]
332    #[case::not_finite(f64::NAN, None)]
333    fn timing_data_requires_non_negative_integer_milliseconds(
334        #[case] value: f64,
335        #[case] expected: Option<u32>,
336    ) -> Result<()> {
337        verify_that!(millisecond_count(value, "test timing").ok(), eq(expected))
338    }
339
340    #[gtest]
341    #[rstest]
342    #[case::zero(0.0, None)]
343    #[case::positive(1.0, Some(1))]
344    #[case::fractional(1.5, None)]
345    fn repeating_intervals_require_positive_integer_milliseconds(
346        #[case] value: f64,
347        #[case] expected: Option<u32>,
348    ) -> Result<()> {
349        verify_that!(
350            positive_millisecond_count(value, "test interval").ok(),
351            eq(expected)
352        )
353    }
354}