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#[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
102pub(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 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 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 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}