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