Skip to main content

wowlab_engine_content/expansion_traits/
omnium.rs

1//! Midnight's Omnium expansion trait tree.
2
3use wowlab_engine_combat::{
4    AuraData, AuraOps as _, BuffEffect, BuiltCombatSystem, DamageOps as _, HookCtx, ImpactEvent,
5    ImpactProc, MAX_AURA_BUFF_EFFECTS, PeriodicData, PeriodicKind, ProcOps as _, SpellEffectRange,
6};
7use wowlab_engine_domain::dbc::{EffectLookup, item_scaled_effect_value};
8use wowlab_engine_gamedata::ResolvedGameData;
9use wowlab_engine_ports::{EngineError, HandlerParams, ResolveDependencies};
10use wowlab_types::{
11    constants::{HUNDRED, MS_PER_SECOND},
12    data::{RefreshBehavior, SpellLabel},
13    game::RatingType,
14    sim::{AuraOn, EffectRef, SpellIdx},
15};
16
17const OMNIUM_ITEM_LEVEL: i32 = 289;
18const CORE_COEFFICIENT_EFFECT: u8 = 1;
19const PAYLOAD_EFFECT: u8 = 2;
20const STAT_COEFFICIENT_EFFECT: u8 = 3;
21
22mod spell {
23    pub(super) const VOID_TOUCHED_ORBS: u32 = 1_279_596;
24    pub(super) const UNLEASHED_FIRE: u32 = 1_279_599;
25    pub(super) const LINGERING: u32 = 1_287_555;
26    pub(super) const CRITICAL_POWER: u32 = 1_279_609;
27    pub(super) const BURNING_HASTE: u32 = 1_279_610;
28    pub(super) const MASTERFUL_CUNNING: u32 = 1_279_612;
29    pub(super) const VERSATILE_WARRIOR: u32 = 1_279_613;
30    pub(super) const OVERLOAD: u32 = 1_279_614;
31    pub(super) const RESIDUAL_ENERGY: u32 = 1_279_615;
32    pub(super) const ECHOES: u32 = 1_279_616;
33
34    pub(super) const COEFFICIENTS: u32 = 1_288_183;
35    pub(super) const VOID_TOUCHED_ORBS_DAMAGE: u32 = 1_286_716;
36    pub(super) const VOID_TOUCHED_ORBS_COUNTER: u32 = 1_287_425;
37    pub(super) const UNLEASHED_FIRE_DAMAGE: u32 = 1_286_970;
38    pub(super) const LINGERING_DAMAGE: u32 = 1_287_663;
39    pub(super) const CRITICAL_POWER_BUFF: u32 = 1_287_772;
40    pub(super) const BURNING_HASTE_BUFF: u32 = 1_287_774;
41    pub(super) const MASTERFUL_CUNNING_BUFF: u32 = 1_287_771;
42    pub(super) const VERSATILE_WARRIOR_BUFF: u32 = 1_287_770;
43    pub(super) const ECHOES_DEBUFF: u32 = 1_289_063;
44    pub(super) const ECHOES_DAMAGE: u32 = 1_303_048;
45}
46
47const VOID_TOUCHED_ORBS_IDS: &[u32] = &[
48    spell::COEFFICIENTS,
49    spell::VOID_TOUCHED_ORBS_DAMAGE,
50    spell::VOID_TOUCHED_ORBS_COUNTER,
51];
52const UNLEASHED_FIRE_IDS: &[u32] = &[spell::COEFFICIENTS, spell::UNLEASHED_FIRE_DAMAGE];
53const LINGERING_IDS: &[u32] = &[spell::LINGERING_DAMAGE];
54const CRITICAL_POWER_IDS: &[u32] = &[spell::CRITICAL_POWER_BUFF];
55const BURNING_HASTE_IDS: &[u32] = &[spell::BURNING_HASTE_BUFF];
56const MASTERFUL_CUNNING_IDS: &[u32] = &[spell::MASTERFUL_CUNNING_BUFF];
57const VERSATILE_WARRIOR_IDS: &[u32] = &[spell::VERSATILE_WARRIOR_BUFF];
58const ECHOES_IDS: &[u32] = &[spell::ECHOES_DEBUFF, spell::ECHOES_DAMAGE];
59
60pub(super) const RESOLVE_DEPENDENCIES: &[ResolveDependencies] = &[
61    ResolveDependencies::new(spell::VOID_TOUCHED_ORBS, VOID_TOUCHED_ORBS_IDS),
62    ResolveDependencies::new(spell::UNLEASHED_FIRE, UNLEASHED_FIRE_IDS),
63    ResolveDependencies::new(spell::LINGERING, LINGERING_IDS),
64    ResolveDependencies::new(spell::CRITICAL_POWER, CRITICAL_POWER_IDS),
65    ResolveDependencies::new(spell::BURNING_HASTE, BURNING_HASTE_IDS),
66    ResolveDependencies::new(spell::MASTERFUL_CUNNING, MASTERFUL_CUNNING_IDS),
67    ResolveDependencies::new(spell::VERSATILE_WARRIOR, VERSATILE_WARRIOR_IDS),
68    ResolveDependencies::new(spell::ECHOES, ECHOES_IDS),
69];
70
71pub(super) fn register(
72    built: &mut BuiltCombatSystem,
73    params: &HandlerParams<'_>,
74) -> Result<(), EngineError> {
75    let unleashed_fire = params.talent_picked(spell::UNLEASHED_FIRE);
76    let void_touched_orbs = params.talent_picked(spell::VOID_TOUCHED_ORBS);
77
78    if !unleashed_fire && !void_touched_orbs {
79        return Ok(());
80    }
81
82    let core_proc = register_core_rune(built, &params.game_data, unleashed_fire)?;
83
84    if params.talent_picked(spell::LINGERING) {
85        register_lingering(built, &params.game_data)?;
86    }
87
88    if let Some(stat) = selected_stat_rune(params) {
89        register_stat_buff(built, &params.game_data, stat)?;
90    }
91
92    built.register_impact_proc(ImpactProc::new(core_proc));
93
94    if params.talent_picked(spell::ECHOES) {
95        register_echoes(built, &params.game_data)?;
96    }
97
98    Ok(())
99}
100
101fn register_core_rune(
102    built: &mut BuiltCombatSystem,
103    data: &ResolvedGameData,
104    unleashed_fire: bool,
105) -> Result<wowlab_engine_combat::ImpactProcFn, EngineError> {
106    let damage_spell_id = if unleashed_fire {
107        spell::UNLEASHED_FIRE_DAMAGE
108    } else {
109        spell::VOID_TOUCHED_ORBS_DAMAGE
110    };
111
112    core_scaled_payload(data, damage_spell_id)
113        .filter(|payload| payload.is_finite() && *payload > 0.0)
114        .ok_or_else(|| EngineError::spec_construction("Omnium core payload is unresolved"))?;
115
116    if unleashed_fire {
117        let _ = built.register_system_rppm_from_data(spell::UNLEASHED_FIRE)?;
118
119        Ok(unleashed_fire_proc)
120    } else {
121        register_void_orbs(built, data)?;
122
123        Ok(void_touched_orbs_proc)
124    }
125}
126
127fn register_void_orbs(
128    built: &mut BuiltCombatSystem,
129    data: &ResolvedGameData,
130) -> Result<(), EngineError> {
131    let counter = SpellIdx::from_raw(spell::VOID_TOUCHED_ORBS_COUNTER);
132    let driver = SpellIdx::from_raw(spell::VOID_TOUCHED_ORBS);
133    let max_stacks = data
134        .aura_max_stacks(counter)
135        .filter(|stacks| *stacks > 0)
136        .ok_or_else(|| {
137            EngineError::spec_construction("Omnium Void-Touched Orbs maximum is unresolved")
138        })?;
139    let tick_interval_ms = data
140        .aura_tick_ms(driver)
141        .filter(|period| *period > 0)
142        .ok_or_else(|| {
143            EngineError::spec_construction(
144                "Omnium Void-Touched Orbs generation period is unresolved",
145            )
146        })?;
147    let aura = AuraData {
148        aura_id: spell::VOID_TOUCHED_ORBS_COUNTER,
149        name_idx: 0,
150        on: AuraOn::Player,
151        propagates_to_pet: false,
152        base_duration_ms: 0,
153        doses: 1,
154        max_stacks,
155        pandemic: false,
156        refresh_behavior: RefreshBehavior::Duration,
157        duration_hasted: false,
158        shapeshift_form: 0,
159        shapeshift_form_flags: 0,
160        shapeshift_combat_round_time_ms: 0,
161        effects: [None; MAX_AURA_BUFF_EFFECTS],
162        periodic: Some(PeriodicData {
163            tick_interval_ms,
164            damage_spell_id: 0,
165            effect_ref: None,
166            hasted_ticks: false,
167            partial_tick: false,
168            may_crit: false,
169            damage_is_periodic: true,
170            tick_on_application: false,
171            effect: PeriodicKind::Hook,
172            resource_gain: 0.0,
173            crit_resource_gain: 0.0,
174            crit_resource_chance: 0.0,
175        }),
176        spell_group: None,
177        granted_aura_state: None,
178        crowd_control: None,
179        is_snapshot: false,
180        dynamic_tick_action: wowlab_engine_combat::DynamicTickAction::None,
181        apply_at_max_stacks: true,
182        reverse: false,
183        freeze_stacks: false,
184        tick_behavior: wowlab_engine_combat::AuraTickBehavior::None,
185        tick_stack_change: 1,
186        async_stacks: false,
187        periodic_damage_scales_with_stacks: false,
188        interrupt_flags: wowlab_engine_domain::dbc::SpellAuraInterruptFlags::empty(),
189        reapply_on_expire: false,
190        application_excluded_auras: [0; wowlab_engine_combat::MAX_AURA_APPLICATION_EXCLUSIONS],
191        application_followup_aura_id: 0,
192        apply_effects: SpellEffectRange::default(),
193        apply_profile_spell_id: 0,
194        on_consume: SpellEffectRange::default(),
195        tick_effects: SpellEffectRange::default(),
196        tick_profile_spell_id: 0,
197        expire_trigger_spell_id: 0,
198        expire_trigger_from_caster: false,
199        on_expire: None,
200        on_tick: Some(generate_void_orb),
201    };
202    let local = built.register_aura("Rune of Void-Touched Orbs", aura)?;
203
204    built.push_precombat_aura(local);
205
206    Ok(())
207}
208
209fn generate_void_orb(ctx: &mut HookCtx<'_>) {
210    ctx.add_aura_stack_id(spell::VOID_TOUCHED_ORBS_COUNTER);
211}
212
213fn is_class_ability(data: &ResolvedGameData, spell_id: u32) -> bool {
214    data.has_label(SpellIdx::from_raw(spell_id), SpellLabel::CLASS_SPELLS.0)
215}
216
217fn unleashed_fire_proc(ctx: &mut HookCtx<'_>, impact: ImpactEvent) {
218    if !is_class_ability(ctx.game_data(), impact.spell_id) {
219        return;
220    }
221
222    if !ctx.roll_system_rppm(spell::UNLEASHED_FIRE) {
223        return;
224    }
225
226    fire_core_rune(ctx, spell::UNLEASHED_FIRE_DAMAGE);
227}
228
229fn void_touched_orbs_proc(ctx: &mut HookCtx<'_>, impact: ImpactEvent) {
230    if impact.amount <= 0.0 || !is_class_ability(ctx.game_data(), impact.spell_id) {
231        return;
232    }
233
234    let orbs = ctx.take_aura_stacks_id(spell::VOID_TOUCHED_ORBS_COUNTER);
235
236    for _ in 0..orbs {
237        fire_core_rune(ctx, spell::VOID_TOUCHED_ORBS_DAMAGE);
238    }
239}
240
241fn fire_core_rune(ctx: &mut HookCtx<'_>, damage_spell_id: u32) {
242    let mut amount = core_scaled_payload(ctx.game_data(), damage_spell_id)
243        .expect("Omnium core payload was validated during construction");
244
245    if spell_present(ctx.game_data(), spell::OVERLOAD) {
246        amount *= 1.0
247            + percent_effect(EffectLookup::new(
248                ctx.game_data(),
249                EffectRef::new(SpellIdx::from_raw(spell::OVERLOAD), CORE_COEFFICIENT_EFFECT),
250            ));
251    }
252
253    ctx.deal_damage_flat(damage_spell_id, amount);
254
255    if spell_present(ctx.game_data(), spell::LINGERING) {
256        ctx.apply_aura_id(spell::LINGERING_DAMAGE);
257    }
258
259    for buff in [
260        spell::CRITICAL_POWER_BUFF,
261        spell::BURNING_HASTE_BUFF,
262        spell::MASTERFUL_CUNNING_BUFF,
263        spell::VERSATILE_WARRIOR_BUFF,
264    ] {
265        if ctx
266            .game_data()
267            .aura_duration_ms(SpellIdx::from_raw(buff))
268            .is_some()
269        {
270            ctx.apply_aura_id(buff);
271        }
272    }
273}
274
275fn register_echoes(
276    built: &mut BuiltCombatSystem,
277    data: &ResolvedGameData,
278) -> Result<(), EngineError> {
279    let duration_ms = data
280        .aura_duration_ms(SpellIdx::from_raw(spell::ECHOES_DEBUFF))
281        .filter(|duration| *duration > 0)
282        .ok_or_else(|| EngineError::spec_construction("Omnium Echoes duration is unresolved"))?;
283    let mut aura = AuraData::simple_player_buff(
284        spell::ECHOES_DEBUFF,
285        0,
286        duration_ms,
287        [None; MAX_AURA_BUFF_EFFECTS],
288    );
289
290    aura.on = AuraOn::Target;
291    aura.on_expire = Some(expire_echoes);
292    let _ = built.register_aura("Rune of Echoes", aura)?;
293
294    built.register_impact_proc(
295        ImpactProc::new(accumulate_echoes).with_spell_filter(is_echoed_omnium_damage),
296    );
297
298    Ok(())
299}
300
301fn is_echoed_omnium_damage(spell_id: u32) -> bool {
302    matches!(
303        spell_id,
304        spell::UNLEASHED_FIRE_DAMAGE | spell::VOID_TOUCHED_ORBS_DAMAGE | spell::LINGERING_DAMAGE
305    )
306}
307
308fn accumulate_echoes(ctx: &mut HookCtx<'_>, impact: ImpactEvent) {
309    if ctx.is_aura_active_id(spell::ECHOES_DEBUFF) {
310        let coefficient = percent_effect(EffectLookup::new(
311            ctx.game_data(),
312            EffectRef::new(SpellIdx::from_raw(spell::ECHOES), CORE_COEFFICIENT_EFFECT),
313        ));
314
315        if let Some(local) = ctx.aura_local_index(spell::ECHOES_DEBUFF) {
316            ctx.accumulate_damage(local, impact.amount * coefficient);
317        }
318    } else {
319        ctx.apply_aura_id(spell::ECHOES_DEBUFF);
320    }
321}
322
323fn expire_echoes(ctx: &mut HookCtx<'_>) {
324    let Some(local) = ctx.aura_local_index(spell::ECHOES_DEBUFF) else {
325        return;
326    };
327    let amount = ctx.take_accumulated_damage(local);
328
329    if amount > 0.0 {
330        ctx.deal_damage_flat(spell::ECHOES_DAMAGE, amount);
331    }
332}
333
334fn core_scaled_payload(data: &ResolvedGameData, payload_spell_id: u32) -> Option<f64> {
335    let coefficient = item_scaled_effect_value(
336        EffectLookup::new(
337            data,
338            EffectRef::new(
339                SpellIdx::from_raw(spell::COEFFICIENTS),
340                CORE_COEFFICIENT_EFFECT,
341            ),
342        ),
343        OMNIUM_ITEM_LEVEL,
344    )
345    .map(f64::floor)?
346        / HUNDRED;
347
348    Some(coefficient * data.base_points(SpellIdx::from_raw(payload_spell_id), PAYLOAD_EFFECT))
349}
350
351fn percent_effect(lookup: EffectLookup<'_>) -> f64 {
352    lookup.base_points() / HUNDRED
353}
354
355fn spell_present(data: &ResolvedGameData, spell_id: u32) -> bool {
356    data.max_effect_index(SpellIdx::from_raw(spell_id)) > 0
357}
358
359#[derive(Clone, Copy)]
360struct StatRune {
361    buff_spell_id: u32,
362    rating: RatingType,
363    effect: fn(f64) -> BuffEffect,
364    name: &'static str,
365}
366
367fn selected_stat_rune(params: &HandlerParams<'_>) -> Option<StatRune> {
368    [
369        (
370            spell::CRITICAL_POWER,
371            StatRune {
372                buff_spell_id: spell::CRITICAL_POWER_BUFF,
373                rating: RatingType::Crit,
374                effect: BuffEffect::Crit,
375                name: "Rune of Critical Power",
376            },
377        ),
378        (
379            spell::BURNING_HASTE,
380            StatRune {
381                buff_spell_id: spell::BURNING_HASTE_BUFF,
382                rating: RatingType::Haste,
383                effect: BuffEffect::Haste,
384                name: "Rune of Burning Haste",
385            },
386        ),
387        (
388            spell::MASTERFUL_CUNNING,
389            StatRune {
390                buff_spell_id: spell::MASTERFUL_CUNNING_BUFF,
391                rating: RatingType::Mastery,
392                effect: BuffEffect::Mastery,
393                name: "Rune of Masterful Cunning",
394            },
395        ),
396        (
397            spell::VERSATILE_WARRIOR,
398            StatRune {
399                buff_spell_id: spell::VERSATILE_WARRIOR_BUFF,
400                rating: RatingType::Versatility,
401                effect: BuffEffect::Versatility,
402                name: "Rune of the Versatile Warrior",
403            },
404        ),
405    ]
406    .into_iter()
407    .find_map(|(talent, rune)| params.talent_picked(talent).then_some(rune))
408}
409
410fn register_stat_buff(
411    built: &mut BuiltCombatSystem,
412    data: &ResolvedGameData,
413    rune: StatRune,
414) -> Result<(), EngineError> {
415    let scaled = item_scaled_effect_value(
416        EffectLookup::new(
417            data,
418            EffectRef::new(
419                SpellIdx::from_raw(spell::COEFFICIENTS),
420                STAT_COEFFICIENT_EFFECT,
421            ),
422        ),
423        OMNIUM_ITEM_LEVEL,
424    )
425    .ok_or_else(|| EngineError::spec_construction("Omnium stat coefficient is unresolved"))?
426    .floor()
427        / HUNDRED;
428    let rating = scaled * data.base_points(SpellIdx::from_raw(rune.buff_spell_id), PAYLOAD_EFFECT);
429    let divisor = wowlab_engine_domain::stats::combat_rating_divisor(
430        data.game_tables(),
431        rune.rating,
432        data.level(),
433    )
434    .ok_or_else(|| EngineError::spec_construction("Omnium combat-rating divisor is unresolved"))?;
435    let mut effects = [None; MAX_AURA_BUFF_EFFECTS];
436
437    effects[0] = Some((rune.effect)(rating / divisor));
438
439    let mut aura = AuraData::simple_player_buff(
440        rune.buff_spell_id,
441        0,
442        data.aura_duration_ms(SpellIdx::from_raw(rune.buff_spell_id))
443            .filter(|duration| *duration > 0)
444            .ok_or_else(|| EngineError::spec_construction("Omnium stat duration is unresolved"))?,
445        effects,
446    );
447
448    aura.max_stacks = data
449        .aura_max_stacks(SpellIdx::from_raw(rune.buff_spell_id))
450        .filter(|stacks| *stacks > 0)
451        .ok_or_else(|| EngineError::spec_construction("Omnium stat stack count is unresolved"))?;
452    aura.async_stacks = true;
453    let _ = built.register_aura(rune.name, aura)?;
454
455    Ok(())
456}
457
458fn register_lingering(
459    built: &mut BuiltCombatSystem,
460    data: &ResolvedGameData,
461) -> Result<(), EngineError> {
462    let lingering_payload = core_scaled_payload(data, spell::LINGERING_DAMAGE)
463        .filter(|payload| payload.is_finite() && *payload > 0.0)
464        .ok_or_else(|| {
465            EngineError::spec_construction("Omnium lingering damage payload is unresolved")
466        })?;
467
468    let duration_ms = data
469        .aura_duration_ms(SpellIdx::from_raw(spell::LINGERING_DAMAGE))
470        .filter(|duration| *duration > 0)
471        .ok_or_else(|| EngineError::spec_construction("Omnium Lingering duration is unresolved"))?;
472    let tick_interval_ms = data
473        .aura_tick_ms(SpellIdx::from_raw(spell::LINGERING_DAMAGE))
474        .filter(|period| *period > 0)
475        .ok_or_else(|| EngineError::spec_construction("Omnium Lingering period is unresolved"))?;
476    let residual_mult = if spell_present(data, spell::RESIDUAL_ENERGY) {
477        1.0 + percent_effect(EffectLookup::new(
478            data,
479            EffectRef::new(
480                SpellIdx::from_raw(spell::RESIDUAL_ENERGY),
481                CORE_COEFFICIENT_EFFECT,
482            ),
483        ))
484    } else {
485        1.0
486    };
487    let tick_amount =
488        lingering_payload / (f64::from(duration_ms) / MS_PER_SECOND).max(1.0) * residual_mult;
489    let aura = AuraData {
490        aura_id: spell::LINGERING_DAMAGE,
491        name_idx: 0,
492        on: AuraOn::Target,
493        propagates_to_pet: false,
494        base_duration_ms: duration_ms,
495        doses: 1,
496        max_stacks: 1,
497        pandemic: false,
498        refresh_behavior: RefreshBehavior::Duration,
499        duration_hasted: false,
500        shapeshift_form: 0,
501        shapeshift_form_flags: 0,
502        shapeshift_combat_round_time_ms: 0,
503        effects: [None; MAX_AURA_BUFF_EFFECTS],
504        periodic: Some(PeriodicData {
505            tick_interval_ms,
506            damage_spell_id: spell::LINGERING_DAMAGE,
507            effect_ref: None,
508            hasted_ticks: false,
509            partial_tick: false,
510            may_crit: false,
511            damage_is_periodic: true,
512            tick_on_application: false,
513            effect: PeriodicKind::FlatDamage {
514                amount: tick_amount,
515                is_physical: false,
516            },
517            resource_gain: 0.0,
518            crit_resource_gain: 0.0,
519            crit_resource_chance: 0.0,
520        }),
521        spell_group: None,
522        granted_aura_state: None,
523        crowd_control: None,
524        is_snapshot: false,
525        dynamic_tick_action: wowlab_engine_combat::DynamicTickAction::None,
526        apply_at_max_stacks: false,
527        reverse: false,
528        freeze_stacks: false,
529        tick_behavior: wowlab_engine_combat::AuraTickBehavior::None,
530        tick_stack_change: 1,
531        async_stacks: false,
532        periodic_damage_scales_with_stacks: false,
533        interrupt_flags: wowlab_engine_domain::dbc::SpellAuraInterruptFlags::empty(),
534        reapply_on_expire: false,
535        application_excluded_auras: [0; wowlab_engine_combat::MAX_AURA_APPLICATION_EXCLUSIONS],
536        application_followup_aura_id: 0,
537        apply_effects: SpellEffectRange::default(),
538        apply_profile_spell_id: 0,
539        on_consume: SpellEffectRange::default(),
540        tick_effects: SpellEffectRange::default(),
541        tick_profile_spell_id: 0,
542        expire_trigger_spell_id: 0,
543        expire_trigger_from_caster: false,
544        on_expire: None,
545        on_tick: None,
546    };
547    let _ = built.register_aura("Rune of Lingering", aura)?;
548
549    Ok(())
550}
551
552#[cfg(test)]
553mod tests {
554    use googletest::prelude::*;
555    use rstest::rstest;
556    use wowlab_engine_ports::CombatStats;
557
558    use super::*;
559
560    #[gtest]
561    #[rstest]
562    #[case::class_ability(&[SpellLabel::CLASS_SPELLS.0], true)]
563    #[case::item_effect(&[SpellLabel::ITEM_EFFECTS.0], false)]
564    #[case::unlabelled(&[], false)]
565    fn unleashed_fire_uses_the_class_spell_label(
566        #[case] labels: &[i32],
567        #[case] expected: bool,
568    ) -> Result<()> {
569        let spell_id = SpellIdx::from_raw(123);
570        let mut builder = ResolvedGameData::builder();
571
572        builder.insert_spell_labels(spell_id, labels.to_vec());
573
574        verify_that!(
575            is_class_ability(&builder.build(), spell_id.as_u32()),
576            eq(expected)
577        )
578    }
579
580    #[gtest]
581    fn selected_omnium_mechanics_fail_closed_without_runtime_data() -> Result<()> {
582        let data = ResolvedGameData::default();
583        let mut void_orbs = crate::test_combat_builder(CombatStats::default())
584            .build(wowlab_types::sim::Rotation::empty())
585            .or_fail()?;
586        let mut echoes = crate::test_combat_builder(CombatStats::default())
587            .build(wowlab_types::sim::Rotation::empty())
588            .or_fail()?;
589        let mut lingering = crate::test_combat_builder(CombatStats::default())
590            .build(wowlab_types::sim::Rotation::empty())
591            .or_fail()?;
592
593        verify_that!(register_void_orbs(&mut void_orbs, &data).is_err(), eq(true))?;
594        verify_that!(register_echoes(&mut echoes, &data).is_err(), eq(true))?;
595
596        verify_that!(register_lingering(&mut lingering, &data).is_err(), eq(true))
597    }
598
599    #[gtest]
600    #[rstest]
601    #[case::void_orbs(
602        spell::VOID_TOUCHED_ORBS,
603        &[
604            spell::COEFFICIENTS,
605            spell::VOID_TOUCHED_ORBS_DAMAGE,
606            spell::VOID_TOUCHED_ORBS_COUNTER,
607        ]
608    )]
609    #[case::unleashed_fire(
610        spell::UNLEASHED_FIRE,
611        &[spell::COEFFICIENTS, spell::UNLEASHED_FIRE_DAMAGE]
612    )]
613    #[case::echoes(
614        spell::ECHOES,
615        &[spell::ECHOES_DEBUFF, spell::ECHOES_DAMAGE]
616    )]
617    fn core_runes_resolve_their_runtime_dependencies(
618        #[case] talent: u32,
619        #[case] expected: &[u32],
620    ) -> Result<()> {
621        let actual = RESOLVE_DEPENDENCIES
622            .iter()
623            .find(|dependency| dependency.selector_id() == talent)
624            .map_or(&[][..], |dependency| dependency.spell_ids());
625
626        verify_that!(actual, eq(expected))
627    }
628
629    #[gtest]
630    #[rstest]
631    #[case::unleashed_fire(spell::UNLEASHED_FIRE_DAMAGE, true)]
632    #[case::void_orbs(spell::VOID_TOUCHED_ORBS_DAMAGE, true)]
633    #[case::lingering(spell::LINGERING_DAMAGE, true)]
634    #[case::echo_damage(spell::ECHOES_DAMAGE, false)]
635    #[case::class_spell(123, false)]
636    fn echoes_accumulates_only_core_and_lingering_damage(
637        #[case] spell_id: u32,
638        #[case] expected: bool,
639    ) -> Result<()> {
640        verify_that!(is_echoed_omnium_damage(spell_id), eq(expected))
641    }
642}