1use wowlab_engine_combat::AuraOps as _;
2use wowlab_engine_combat::{ImpactEvent, ImpactProc};
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, 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
17fn catastrophe_impact(ctx: &mut wowlab_engine_combat::HookCtx<'_>, impact: ImpactEvent) {
18 let fraction = annihilator_config(ctx).catastrophe_pct;
19
20 ctx.add_residual_damage(AURA_CATASTROPHE.raw(), impact.amount * fraction);
21}
22
23#[derive(Clone, Copy, Debug)]
24pub(super) struct AnnihilatorRuntime {
25 pub(super) last_meta_ms: u32,
26}
27
28impl Default for AnnihilatorRuntime {
29 fn default() -> Self {
30 Self {
31 last_meta_ms: u32::MAX,
32 }
33 }
34}
35
36crate::hooks::define_spec_config! {
37 pub(super) struct AnnihilatorConfig {
39 voidfall: bool { provenance: "Voidfall enables the building and spending loop.", gate: voidfall, source: true, transform: |value| value },
40 voidfall_chance: f64 { provenance: "Voidfall average proc rate converted to its accumulated-RNG constant.", gate: voidfall, source: pct(EFFECT::VOIDFALL_CHANCE)?, transform: prd_constant },
41 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 },
42 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 },
43 meteoric_fall: bool { provenance: "Meteoric Fall enables simultaneous Voidfall spending.", gate: meteoric_fall, source: true, transform: |value| value },
44 world_killer: bool { provenance: "World Killer replaces the last meteor.", gate: world_killer, source: true, transform: |value| value },
45 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 },
46 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 },
47 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) },
48 catastrophe_pct: f64 { provenance: "Catastrophe residual damage fraction.", gate: catastrophe, source: pct(EFFECT::CATASTROPHE_PCT)?, transform: |value| value },
49 dark_matter: bool { provenance: "Dark Matter enables Meteor Shower.", gate: dark_matter, source: true, transform: |value| value },
50 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 },
51 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 },
52 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 },
53 spirit_bomb_mult: f64 { provenance: "Otherworldly Focus multiplier applied to Spirit Bomb.", gate: voidfall, source: otherworldly, transform: |value| 1.0 + value, fallback: 1.0 },
54 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 },
55 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 },
56 }
57 accessor pub(super) fn annihilator_config(ctx);
58 register pub(super) fn register_annihilator(built, params) -> Result<(), wowlab_engine_ports::EngineError>;
59 prepare {
60 let data = ¶ms.game_data;
61 let pct = |eff: (u32, u8)| -> Result<f64, wowlab_engine_ports::EngineError> {
62 Ok(data.require_base_points(SpellIdx::from_raw(eff.0), eff.1)? / HUNDRED)
63 };
64 let voidfall = params.talent_ranks(HERO::ANNIHILATOR::SPELL::VOIDFALL) > 0;
65 let meteoric_fall = voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::METEORIC_FALL) > 0;
66 let world_killer = voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::WORLD_KILLER) > 0;
67 let catastrophe = voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::CATASTROPHE) > 0;
68 let mass_acceleration = voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::MASS_ACCELERATION) > 0;
69 let harness = try_gated(
70 voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::HARNESS_THE_COSMOS) > 0,
71 || pct(EFFECT::HARNESS_THE_COSMOS_PCT),
72 )?;
73 let otherworldly = try_gated(
74 voidfall && params.talent_ranks(HERO::ANNIHILATOR::SPELL::OTHERWORLDLY_FOCUS) > 0,
75 || pct(EFFECT::OTHERWORLDLY_FOCUS_PCT),
76 )?;
77 let voidfall_mult = (1.0 + harness) * (1.0 + otherworldly);
78 let dark_matter = params.talent_ranks(HERO::ANNIHILATOR::SPELL::DARK_MATTER) > 0;
79 let untethered_rage = UNTETHERED_RAGE_TALENTS.iter().any(|&id| params.talent_ranks(id) > 0);
80 }
81 auras {}
82 wiring {
83 { provenance: "Catastrophe receives post-mitigation Annihilator meteor impacts.", gate: catastrophe, apply: {
84 built.register_impact_proc(
85 ImpactProc::new(catastrophe_impact)
86 .with_spell_filter(annihilator_meteor_impact)
87 .skip_periodic(),
88 );
89 } },
90 }
91 finish |cfg| {
92 built.state.set_spec_config(cfg);
93 built
94 .state
95 .set_spec_runtime(AnnihilatorRuntime::default());
96 Ok(())
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use googletest::prelude::*;
103 use wowlab_engine_combat::{BuffEffect, BuiltCombatSystem};
104 use wowlab_engine_domain::dbc::AuraSubtypeKind;
105 use wowlab_engine_ports::{CombatStats, ConsumableFlags, TalentSelection};
106use wowlab_engine_gamedata::{AuraProps, ResolvedGameData, SpellProps};
107 use wowlab_types::{game::RaceId, sim::SpellIdx};
108
109 use super::*;
110 use crate::generated::specs::vengeance_demon_hunter::AURA;
111
112 const UNUSUAL_HASTE: f64 = 37.0;
113
114 fn talent(spell_id: u32) -> TalentSelection {
115 TalentSelection {
116 spell_id,
117 override_spell_id: 0,
118 replaces_spell_id: 0,
119 ranks: 1,
120 precombat_aura: false,
121 hero_tree: Some("annihilator".to_string()),
122 effect_overrides: Vec::new(),
123 }
124 }
125
126 fn registered_voidfall_auras(
127 swift_erasure_selected: bool,
128 folded_haste: f64,
129 ) -> Result<BuiltCombatSystem> {
130 let encounter = wowlab_engine_ports::test_support::introspection_fixture(60.0).or_fail()?;
131 let environment = wowlab_engine_ports::test_support::introspection_game_data(&encounter);
132 let mut data = ResolvedGameData::builder();
133
134 data.set_armor_constant(environment.armor_constant());
135 data.set_creature_armor(environment.creature_armor());
136 data.set_armor_constant_mod(environment.armor_constant_mod());
137
138 for aura_id in [
139 AURA::VOIDFALL_BUILDING,
140 AURA::VOIDFALL_SPENDING,
141 AURA::VOIDFALL_FINAL_HOUR,
142 ] {
143 let aura = SpellIdx::from_raw(aura_id);
144
145 data.insert_aura_props(
146 aura,
147 AuraProps {
148 max_stacks: 20,
149 ..AuraProps::default()
150 },
151 );
152 data.insert_spell_props(aura, SpellProps::default());
153 data.insert_effect_aura(
154 wowlab_types::sim::EffectRef::new(aura, 3),
155 AuraSubtypeKind::HasteAll as i32,
156 );
157 data.insert_base_points(aura, 3, folded_haste);
158 }
159
160 let voidfall = SpellIdx::from_raw(HERO::ANNIHILATOR::SPELL::VOIDFALL);
161
162 data.insert_base_points(voidfall, 1, 1.0);
163 data.insert_base_points(voidfall, 3, 10.0);
164 data.insert_base_points(
165 SpellIdx::from_raw(HERO::ANNIHILATOR::SPELL::SWIFT_ERASURE),
166 1,
167 UNUSUAL_HASTE,
168 );
169
170 let data = data.build();
171 let stats = CombatStats::default();
172 let selected = [
173 talent(HERO::ANNIHILATOR::SPELL::VOIDFALL),
174 talent(HERO::ANNIHILATOR::SPELL::SWIFT_ERASURE),
175 ];
176 let talents = if swift_erasure_selected {
177 selected.as_slice()
178 } else {
179 &[]
180 };
181 let rotation = crate::test_support::wait_rotation();
182 let params =
183 crate::test_support::handler_params(crate::test_support::HandlerParamsFixture {
184 game_data: data.clone(),
185 rotation: &rotation,
186 stats: &stats,
187 talent_selections: talents,
188 encounter: &encounter,
189 consumables: ConsumableFlags::default(),
190 race: RaceId::Human,
191 })
192 .or_fail()?;
193 let mut built = crate::test_combat_builder(CombatStats::default())
194 .game_data(data.clone())
195 .aura("voidfall_building", AURA::VOIDFALL_BUILDING, |aura| {
196 aura.apply_base_from_data(&data, AURA::VOIDFALL_BUILDING)
197 })
198 .aura("voidfall_spending", AURA::VOIDFALL_SPENDING, |aura| {
199 aura.apply_base_from_data(&data, AURA::VOIDFALL_SPENDING)
200 })
201 .aura("voidfall_final_hour", AURA::VOIDFALL_FINAL_HOUR, |aura| {
202 aura.apply_base_from_data(&data, AURA::VOIDFALL_FINAL_HOUR)
203 })
204 .build(crate::test_support::wait_rotation())
205 .or_fail()?;
206
207 register_annihilator(&mut built, ¶ms)?;
208
209 Ok(built)
210 }
211
212 fn verify_single_dbc_haste(
213 built: &BuiltCombatSystem,
214 aura_id: u32,
215 expected: f64,
216 ) -> Result<()> {
217 let local = built.state.aura_local(aura_id).or_fail()?;
218 let effects: Vec<_> = built
219 .aura(local)
220 .or_fail()?
221 .effects
222 .iter()
223 .flatten()
224 .collect();
225
226 verify_that!(effects.len(), eq(1))?;
227 verify_false!(
228 effects
229 .iter()
230 .any(|effect| matches!(effect, BuffEffect::Haste(_)))
231 )?;
232
233 match effects[0] {
234 BuffEffect::HasteMultFromEffect {
235 percent,
236 source_spell_id,
237 effect_index,
238 } => {
239 verify_that!(*percent, near(expected, f64::EPSILON))?;
240 verify_that!(*source_spell_id, eq(aura_id))?;
241
242 verify_that!(*effect_index, eq(3))
243 }
244 effect => fail!("expected one DBC haste effect, got {effect:?}"),
245 }
246 }
247
248 #[gtest]
249 fn swift_erasure_uses_one_folded_dbc_haste_effect_per_voidfall_aura() -> Result<()> {
250 let built = registered_voidfall_auras(true, UNUSUAL_HASTE)?;
251
252 for aura_id in [
253 AURA::VOIDFALL_BUILDING,
254 AURA::VOIDFALL_SPENDING,
255 AURA::VOIDFALL_FINAL_HOUR,
256 ] {
257 verify_single_dbc_haste(&built, aura_id, UNUSUAL_HASTE)?;
258 }
259
260 Ok(())
261 }
262
263 #[gtest]
264 fn unselected_swift_erasure_leaves_zero_valued_dbc_haste_rows() -> Result<()> {
265 let built = registered_voidfall_auras(false, 0.0)?;
266
267 for aura_id in [
268 AURA::VOIDFALL_BUILDING,
269 AURA::VOIDFALL_SPENDING,
270 AURA::VOIDFALL_FINAL_HOUR,
271 ] {
272 verify_single_dbc_haste(&built, aura_id, 0.0)?;
273 }
274
275 Ok(())
276 }
277}
278
279#[cfg(test)]
280#[path = "config/rider_tests.rs"]
281mod rider_tests;