Skip to main content

wowlab_engine_content/hooks/vengeance_demon_hunter/
config.rs

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    /// Talent-gated Annihilator and Untethered Rage parameters.
38    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 = &params.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, &params)?;
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;