Skip to main content

wowlab_engine_content/hooks/vengeance_demon_hunter/
config.rs

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