1use wowlab_engine_combat::AuraOps as _;
2use wowlab_engine_combat::{BuffEffect, CombatProgram, ImpactEvent, ImpactProc, PassiveDriverEffect};
3use wowlab_engine_domain::dbc::ResolvedGameDataEffectExt as _;
4use wowlab_engine_rng::prd_constant;
5use wowlab_types::{
6 constants::{HUNDRED, MS_PER_SECOND},
7 sim::SpellIdx,
8};
9
10use crate::{
11 generated::specs::vengeance_demon_hunter::{AURA_CATASTROPHE, EFFECT, HERO, SET_BONUS, TALENT},
12 hooks::{shared::demon_hunter::annihilator_meteor_impact, try_gated},
13};
14
15use super::hooks::{UNTETHERED_RAGE_CHANCE_PER_SOUL, UNTETHERED_RAGE_TALENTS};
16
17const MID2_VENGEANCE_4PC_SPELLS: &[u32] = &[258_920, 258_921, 258_922, 390_163];
18
19fn mid2_vengeance_target_mult(
20 program: &CombatProgram,
21 instance: &wowlab_engine_combat::CombatInstance,
22 target: Option<wowlab_types::sim::EnemyIdx>,
23 _now: wowlab_types::sim::SimTime,
24) -> f64 {
25 let Some(target) = target else {
26 return 1.0;
27 };
28 let active = wowlab_engine_combat::CombatView::new(program, instance)
29 .is_player_aura_active_on_target(
30 crate::generated::specs::vengeance_demon_hunter::AURA_SIGIL_OF_FLAME,
31 target,
32 );
33
34 if active {
35 program
36 .spec_config::<AnnihilatorConfig>()
37 .map_or(1.0, |cfg| cfg.mid2_4pc_sigil_damage_mult)
38 } else {
39 1.0
40 }
41}
42
43fn catastrophe_impact(ctx: &mut wowlab_engine_combat::HookCtx<'_>, impact: ImpactEvent) {
44 let fraction = annihilator_config(ctx).catastrophe_pct;
45
46 ctx.add_residual_damage(AURA_CATASTROPHE, impact.amount * fraction);
47}
48
49#[derive(Clone, Copy, Debug)]
50pub(super) struct AnnihilatorRuntime {
51 pub(super) last_meta_ms: u32,
52}
53
54impl Default for AnnihilatorRuntime {
55 fn default() -> Self {
56 Self {
57 last_meta_ms: u32::MAX,
58 }
59 }
60}
61
62crate::hooks::define_spec_config! {
63 pub(super) struct AnnihilatorConfig {
65 voidfall: bool { provenance: "Voidfall enables the building and spending loop.", gate: voidfall, source: true, transform: |value| value },
66 voidfall_chance: f64 { provenance: "Voidfall average proc rate converted to its accumulated-RNG constant.", gate: voidfall, source: pct(EFFECT::VOIDFALL_CHANCE)?, transform: prd_constant },
67 voidfall_stacks: i32 { provenance: "Voidfall effect stacks granted per building proc.", gate: voidfall, source: data.require_effect_base_points(EFFECT::VOIDFALL_STACKS)?, transform: wowlab_types::numeric::f64_to_i32_saturating_trunc },
68 voidfall_max_stacks: i32 { provenance: "Voidfall building aura stack cap.", gate: voidfall, source: data.require_aura_max_stacks(SpellIdx::from_raw(HERO::ANNIHILATOR::AURA::VOIDFALL_BUILDING))?, transform: i32::from },
69 meteoric_fall: bool { provenance: "Meteoric Fall enables simultaneous Voidfall spending.", gate: meteoric_fall, source: true, transform: |value| value },
70 world_killer: bool { provenance: "World Killer replaces the last meteor.", gate: world_killer, source: true, transform: |value| value },
71 meteor_coef: f64 { provenance: "Voidfall meteor AP coefficient with Harness and Otherworldly multipliers.", gate: voidfall, source: data.effect_ap_coefficient(EFFECT::VOIDFALL_METEOR_COEF), transform: |value| value * voidfall_mult },
72 world_killer_coef: f64 { provenance: "World Killer AP coefficient with Harness and Otherworldly multipliers.", gate: voidfall, source: data.effect_ap_coefficient(EFFECT::WORLD_KILLER_COEF), transform: |value| value * voidfall_mult },
73 world_killer_meta_cdr_ms: u32 { provenance: "World Killer Metamorphosis cooldown reduction in milliseconds.", gate: world_killer, source: data.require_effect_base_points(EFFECT::WORLD_KILLER_META_CDR)?, transform: |value| wowlab_types::numeric::f64_to_u32_saturating_trunc(value * MS_PER_SECOND) },
74 catastrophe_pct: f64 { provenance: "Catastrophe residual damage fraction.", gate: catastrophe, source: pct(EFFECT::CATASTROPHE_PCT)?, transform: |value| value },
75 dark_matter: bool { provenance: "Dark Matter enables Meteor Shower.", gate: dark_matter, source: true, transform: |value| value },
76 meteor_shower_ticks: i32 { provenance: "Meteor Shower effect tick count.", gate: dark_matter, source: data.require_effect_base_points(EFFECT::METEOR_SHOWER_TICKS)?, transform: wowlab_types::numeric::f64_to_i32_saturating_trunc },
77 meteor_shower_coef: f64 { provenance: "Meteor Shower effect AP coefficient with Harness and Otherworldly multipliers.", gate: dark_matter, source: data.effect_ap_coefficient(EFFECT::METEOR_SHOWER_COEF), transform: |value| value * voidfall_mult },
78 mass_accel_stacks: i32 { provenance: "Mass Acceleration effect haste-stack count.", gate: mass_acceleration, source: data.require_effect_base_points(EFFECT::MASS_ACCELERATION_STACKS)?, transform: wowlab_types::numeric::f64_to_i32_saturating_trunc },
79 spirit_bomb_mult: f64 { provenance: "Otherworldly Focus multiplier applied to Spirit Bomb.", gate: voidfall, source: otherworldly, transform: |value| 1.0 + value, fallback: 1.0 },
80 ur_chance_per_soul: f64 { provenance: "Untethered Rage modeled chance per soul consumed.", gate: untethered_rage, source: UNTETHERED_RAGE_CHANCE_PER_SOUL, transform: |value| value },
81 ur_seething: bool { provenance: "Untethered Rage rank three enables Seething Anger.", gate: untethered_rage && params.talent_ranks(TALENT::UNTETHERED_RAGE_3) > 0, source: true, transform: |value| value },
82 mid2_2pc_sigil_extension_ms: u32 { provenance: "MID2 Vengeance 2pc 1296613 e2 Sigil of Flame extension milliseconds.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID2_VENGEANCE_2PC), source: params.effect_base_points(EFFECT::MID2_2PC_SIGIL_EXTENSION_MS), transform: wowlab_types::numeric::f64_to_u32_saturating_trunc },
83 mid2_4pc_sigil_damage_mult: f64 { provenance: "MID2 Vengeance 4pc increases Immolation Aura and Sigil of Spite damage against targets carrying Sigil of Flame.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID2_VENGEANCE_4PC), source: params.effect_base_points(EFFECT::MID2_4PC_SIGIL_DAMAGE), transform: |value| 1.0 + value / HUNDRED, fallback: 1.0 },
84 }
85 accessor pub(super) fn annihilator_config(ctx);
86 register pub(super) fn register_annihilator(built, params) -> Result<(), wowlab_engine_ports::EngineError>;
87 prepare {
88 let data = ¶ms.game_data;
89 let pct = |eff: (u32, u8)| -> Result<f64, wowlab_engine_ports::EngineError> {
90 Ok(data.require_base_points(SpellIdx::from_raw(eff.0), eff.1)? / HUNDRED)
91 };
92 let voidfall = params.talent_ranks(HERO::ANNIHILATOR::SPELL::VOIDFALL) > 0;
93 let meteoric_fall = voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::METEORIC_FALL) > 0;
94 let world_killer = voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::WORLD_KILLER) > 0;
95 let catastrophe = voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::CATASTROPHE) > 0;
96 let mass_acceleration = voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::MASS_ACCELERATION) > 0;
97 let harness = try_gated(
98 voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::HARNESS_THE_COSMOS) > 0,
99 || pct(EFFECT::HARNESS_THE_COSMOS_PCT),
100 )?;
101 let otherworldly = try_gated(
102 voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::OTHERWORLDLY_FOCUS) > 0,
103 || pct(EFFECT::OTHERWORLDLY_FOCUS_PCT),
104 )?;
105 let voidfall_mult = (1.0 + harness) * (1.0 + otherworldly);
106 let dark_matter = params.talent_ranks(HERO::ANNIHILATOR::SPELL::DARK_MATTER) > 0;
107 let untethered_rage = UNTETHERED_RAGE_TALENTS.iter().any(|&id| params.talent_ranks(id) > 0);
108 }
109 auras {}
110 wiring {
111 { provenance: "MID2 Vengeance four-piece target-conditioned Immolation Aura and Sigil of Spite damage.", gate: params.set_bonus_auras.contains(&SET_BONUS::MID2_VENGEANCE_4PC), apply: {
112 built.register_passive_driver_effect(PassiveDriverEffect::new(
113 SET_BONUS::MID2_VENGEANCE_4PC,
114 BuffEffect::DynamicDamageMultSpells(
115 mid2_vengeance_target_mult,
116 MID2_VENGEANCE_4PC_SPELLS,
117 ),
118 ));
119 } },
120 { provenance: "Catastrophe receives post-mitigation Annihilator meteor impacts.", gate: catastrophe, apply: {
121 built.register_impact_proc(
122 ImpactProc::new(catastrophe_impact)
123 .with_spell_filter(annihilator_meteor_impact)
124 .skip_periodic(),
125 );
126 } },
127 }
128 finish |cfg| {
129 built.set_spec_config(cfg);
130 built.set_spec_runtime(AnnihilatorRuntime::default());
131 Ok(())
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use googletest::prelude::*;
138 use wowlab_engine_combat::{BuffEffect, CombatSystemDraft};
139 use wowlab_engine_domain::dbc::AuraSubtypeKind;
140 use wowlab_engine_ports::{CombatStats, ConsumableFlags, TalentSelection};
141use wowlab_engine_gamedata::{AuraProps, ResolvedGameData, SpellProps};
142 use wowlab_types::{game::RaceId, sim::SpellIdx};
143
144 use super::*;
145 use crate::generated::specs::vengeance_demon_hunter::AURA;
146
147 const UNUSUAL_HASTE: f64 = 37.0;
148
149 fn talent(spell_id: u32) -> TalentSelection {
150 TalentSelection {
151 spell_id,
152 override_spell_id: 0,
153 replaces_spell_id: 0,
154 ranks: 1,
155 precombat_aura: false,
156 hero_tree: Some("annihilator".to_string()),
157 effect_overrides: Vec::new(),
158 }
159 }
160
161 fn registered_voidfall_auras(
162 swift_erasure_selected: bool,
163 folded_haste: f64,
164 ) -> Result<CombatSystemDraft> {
165 let encounter = wowlab_engine_ports::test_support::introspection_fixture(60.0).or_fail()?;
166 let environment = wowlab_engine_ports::test_support::introspection_game_data(&encounter);
167 let mut data = ResolvedGameData::builder();
168
169 data.set_armor_constant(environment.armor_constant());
170 data.set_creature_armor(environment.creature_armor());
171 data.set_armor_constant_mod(environment.armor_constant_mod());
172
173 for aura_id in [
174 AURA::VOIDFALL_BUILDING,
175 AURA::VOIDFALL_SPENDING,
176 AURA::VOIDFALL_FINAL_HOUR,
177 ] {
178 let aura = SpellIdx::from_raw(aura_id);
179
180 data.insert_aura_props(
181 aura,
182 AuraProps {
183 max_stacks: 20,
184 ..AuraProps::default()
185 },
186 );
187 data.insert_spell_props(aura, SpellProps::default());
188 data.insert_effect_aura(
189 wowlab_types::sim::EffectRef::new(aura, 3),
190 AuraSubtypeKind::HasteAll as i32,
191 );
192 data.insert_base_points(aura, 3, folded_haste);
193 }
194
195 let voidfall = SpellIdx::from_raw(HERO::ANNIHILATOR::SPELL::VOIDFALL);
196
197 data.insert_base_points(voidfall, 1, 1.0);
198 data.insert_base_points(voidfall, 3, 10.0);
199 data.insert_base_points(
200 SpellIdx::from_raw(HERO::ANNIHILATOR::SPELL::SWIFT_ERASURE),
201 1,
202 UNUSUAL_HASTE,
203 );
204
205 let data = data.build();
206 let stats = CombatStats::default();
207 let selected = [
208 talent(HERO::ANNIHILATOR::SPELL::VOIDFALL),
209 talent(HERO::ANNIHILATOR::SPELL::SWIFT_ERASURE),
210 ];
211 let talents = if swift_erasure_selected {
212 selected.as_slice()
213 } else {
214 &[]
215 };
216 let rotation = crate::test_support::wait_rotation();
217 let params =
218 crate::test_support::handler_params(crate::test_support::HandlerParamsFixture {
219 game_data: data.clone(),
220 rotation: &rotation,
221 stats: &stats,
222 talent_selections: talents,
223 encounter: &encounter,
224 consumables: ConsumableFlags::default(),
225 race: RaceId::Human,
226 })
227 .or_fail()?;
228 let mut draft = crate::test_combat_builder(CombatStats::default())
229 .game_data(data.clone())
230 .aura("voidfall_building", AURA::VOIDFALL_BUILDING, |aura| {
231 aura.apply_base_from_data(&data, AURA::VOIDFALL_BUILDING)
232 })
233 .aura("voidfall_spending", AURA::VOIDFALL_SPENDING, |aura| {
234 aura.apply_base_from_data(&data, AURA::VOIDFALL_SPENDING)
235 })
236 .aura("voidfall_final_hour", AURA::VOIDFALL_FINAL_HOUR, |aura| {
237 aura.apply_base_from_data(&data, AURA::VOIDFALL_FINAL_HOUR)
238 })
239 .into_draft()
240 .or_fail()?;
241
242 register_annihilator(&mut draft, ¶ms)?;
243
244 Ok(draft)
245 }
246
247 fn verify_single_dbc_haste(
248 built: &CombatSystemDraft,
249 aura_id: u32,
250 expected: f64,
251 ) -> Result<()> {
252 let local = built.aura_local(aura_id).or_fail()?;
253 let effects: Vec<_> = built
254 .aura(local)
255 .or_fail()?
256 .effects
257 .iter()
258 .flatten()
259 .collect();
260
261 verify_that!(effects.len(), eq(1))?;
262 verify_false!(
263 effects
264 .iter()
265 .any(|effect| matches!(effect, BuffEffect::Haste(_)))
266 )?;
267
268 match effects[0] {
269 BuffEffect::HasteMultFromEffect {
270 percent,
271 source_spell_id,
272 effect_index,
273 } => {
274 verify_that!(*percent, near(expected, f64::EPSILON))?;
275 verify_that!(*source_spell_id, eq(aura_id))?;
276
277 verify_that!(*effect_index, eq(3))
278 }
279 effect => fail!("expected one DBC haste effect, got {effect:?}"),
280 }
281 }
282
283 #[gtest]
284 fn swift_erasure_uses_one_folded_dbc_haste_effect_per_voidfall_aura() -> Result<()> {
285 let built = registered_voidfall_auras(true, UNUSUAL_HASTE)?;
286
287 for aura_id in [
288 AURA::VOIDFALL_BUILDING,
289 AURA::VOIDFALL_SPENDING,
290 AURA::VOIDFALL_FINAL_HOUR,
291 ] {
292 verify_single_dbc_haste(&built, aura_id, UNUSUAL_HASTE)?;
293 }
294
295 Ok(())
296 }
297
298 #[gtest]
299 fn unselected_swift_erasure_leaves_zero_valued_dbc_haste_rows() -> Result<()> {
300 let built = registered_voidfall_auras(false, 0.0)?;
301
302 for aura_id in [
303 AURA::VOIDFALL_BUILDING,
304 AURA::VOIDFALL_SPENDING,
305 AURA::VOIDFALL_FINAL_HOUR,
306 ] {
307 verify_single_dbc_haste(&built, aura_id, 0.0)?;
308 }
309
310 Ok(())
311 }
312}
313
314#[cfg(test)]
315#[path = "config/rider_tests.rs"]
316mod rider_tests;