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