wowlab_engine_content/hooks/unholy_death_knight/
config.rs1use 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#[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 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 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 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}