Skip to main content

wowlab_engine_content/
raid_buffs.rs

1//! Raid-wide buffs and consumables (Bloodlust, potion, flask, food, augment rune) registered post-build at sim start.
2
3use wowlab_engine_combat::{
4    AuraData, BuffEffect, BuiltCombatSystem, MAX_AURA_APPLICATION_EXCLUSIONS,
5    MAX_AURA_BUFF_EFFECTS, try_push_effect,
6};
7use wowlab_engine_domain::{
8    dbc::{
9        AuraSubtypeKind, RatingMultiplierMask, SCALING_CLASS_ARMOR_MULTIPLIED,
10        SCALING_CLASS_PRIMARY_STAT, SpellEffectKind, StatModifierKind, aura_subtype_kind,
11        spell_effect_is, spell_scaling_budget,
12    },
13    stats,
14};
15use wowlab_engine_gamedata::{FoodBuff, ItemBudget, ResolvedGameData};
16use wowlab_engine_ports::{CombatStats, EngineError};
17use wowlab_types::{
18    constants::{BLOODLUST_SPELL_ID, HUNDRED},
19    game::RatingType,
20    sim::{AuraIdx, AuraKey, AuraOn, SpellIdx},
21};
22
23/// Bloodlust's ten-minute lockout aura.
24pub(crate) const SATED_SPELL_ID: u32 = 57_724;
25/// Heroism's equivalent ten-minute lockout aura.
26pub(crate) const EXHAUSTION_SPELL_ID: u32 = 57_723;
27const TEMPORAL_DISPLACEMENT_SPELL_ID: u32 = 80_354;
28const FATIGUED_SPELL_ID: u32 = 264_689;
29const EVOKER_EXHAUSTION_SPELL_ID: u32 = 390_435;
30const BLOODLUST_FAMILY: [(u32, u32); MAX_AURA_APPLICATION_EXCLUSIONS] = [
31    (BLOODLUST_SPELL_ID, SATED_SPELL_ID),
32    (32_182, EXHAUSTION_SPELL_ID),
33    (80_353, TEMPORAL_DISPLACEMENT_SPELL_ID),
34    (264_667, FATIGUED_SPELL_ID),
35    (390_386, EVOKER_EXHAUSTION_SPELL_ID),
36];
37const BLOODLUST_DURATION_MS: u32 = 40_000;
38const BLOODLUST_LOCKOUT_DURATION_MS: u32 = 600_000;
39const BLOODLUST_HASTE_EFFECT: u8 = 1;
40
41const POTION_FALLBACK_DURATION_MS: u32 = 30_000;
42const CONSUMABLE_FALLBACK_DURATION_MS: u32 = 3_600_000;
43
44const CONSUMABLE_MAX_EFFECT: u8 = 10;
45
46fn rating_type_from_mask(misc: i32) -> Option<RatingType> {
47    let mask = RatingMultiplierMask::from_bits_retain(misc);
48
49    if mask == RatingMultiplierMask::CRIT {
50        Some(RatingType::Crit)
51    } else if mask == RatingMultiplierMask::HASTE {
52        Some(RatingType::Haste)
53    } else if mask == RatingMultiplierMask::VERSATILITY {
54        Some(RatingType::Versatility)
55    } else if mask == RatingMultiplierMask::MASTERY {
56        Some(RatingType::Mastery)
57    } else {
58        None
59    }
60}
61
62fn primary_stat_budget(data: &ResolvedGameData) -> Result<f64, EngineError> {
63    let level = data.level();
64
65    spell_scaling_budget(data.game_tables(), SCALING_CLASS_PRIMARY_STAT, level).ok_or_else(|| {
66        EngineError::spec_construction(format!(
67            "spell scaling budget (class {SCALING_CLASS_PRIMARY_STAT}) missing for level {level}"
68        ))
69    })
70}
71
72fn rating_stat_budget(data: &ResolvedGameData) -> Result<f64, EngineError> {
73    let level = data.level();
74
75    spell_scaling_budget(data.game_tables(), SCALING_CLASS_ARMOR_MULTIPLIED, level).ok_or_else(
76        || {
77            EngineError::spec_construction(format!(
78                "spell scaling budget (class {SCALING_CLASS_ARMOR_MULTIPLIED}) missing for level {level}"
79            ))
80        },
81    )
82}
83
84pub(crate) fn rating_percent_points(
85    data: &ResolvedGameData,
86    rating_type: RatingType,
87    amount: f64,
88) -> Result<f64, EngineError> {
89    stats::rating_percent_points(data, rating_type, amount).map_err(|error| {
90        EngineError::spec_construction(format!("rating conversion failed: {error}"))
91    })
92}
93
94fn rating_buff_effect(
95    lookup: wowlab_engine_domain::dbc::EffectLookup<'_>,
96    rating_type: RatingType,
97    amount: f64,
98) -> Result<BuffEffect, EngineError> {
99    let data = lookup.data;
100    let spell_id = lookup.effect.spell.as_u32();
101    let effect_index = lookup.effect.effect_index;
102    let percent = amount.signum() * rating_percent_points(data, rating_type, amount.abs())?;
103
104    Ok(match rating_type {
105        RatingType::Crit => BuffEffect::Crit(percent),
106        RatingType::Haste => BuffEffect::Haste(percent),
107        RatingType::Versatility => BuffEffect::Versatility(percent),
108        RatingType::Mastery => BuffEffect::Mastery(percent),
109        RatingType::Leech | RatingType::Avoidance | RatingType::Speed => {
110            return Err(EngineError::spec_construction(format!(
111                "unmodelled tertiary rating {rating_type:?} for spell {spell_id} effect {effect_index}"
112            )));
113        }
114    })
115}
116
117fn clamp_rating_penalty(current_rating: f64, rating_delta: f64) -> f64 {
118    rating_delta.max(-current_rating.max(0.0))
119}
120
121pub(crate) type BuildBuffEffectsFn =
122    fn(&ResolvedGameData) -> Result<[Option<BuffEffect>; MAX_AURA_BUFF_EFFECTS], EngineError>;
123
124#[derive(Clone, Copy, Debug)]
125enum ConsumableSlot {
126    Potion,
127    Flask,
128    Food,
129    AugmentRune,
130}
131
132#[derive(Clone, Copy, Debug)]
133enum BuffSource {
134    Fixed {
135        spell_id: u32,
136        name: &'static str,
137        fallback_duration_ms: u32,
138        build_effects: BuildBuffEffectsFn,
139    },
140    Consumable(ConsumableSlot),
141}
142
143/// Table-row describing one precombat buff (racial, raid buff, or consumable slot).
144#[derive(Clone, Copy, Debug)]
145pub(crate) struct PrecombatBuffSpec {
146    source: BuffSource,
147}
148
149impl PrecombatBuffSpec {
150    /// A precombat buff for a fixed spell id with a bespoke effect builder.
151    pub(crate) const fn fixed(
152        spell_id: u32,
153        name: &'static str,
154        fallback_duration_ms: u32,
155        build_effects: BuildBuffEffectsFn,
156    ) -> Self {
157        Self {
158            source: BuffSource::Fixed {
159                spell_id,
160                name,
161                fallback_duration_ms,
162                build_effects,
163            },
164        }
165    }
166
167    const fn consumable(slot: ConsumableSlot) -> Self {
168        Self {
169            source: BuffSource::Consumable(slot),
170        }
171    }
172}
173
174fn consumable_effect_at(
175    lookup: wowlab_engine_domain::dbc::EffectLookup<'_>,
176    item_budget: Option<ItemBudget>,
177) -> Result<Option<BuffEffect>, EngineError> {
178    let data = lookup.data;
179    let idx = lookup.effect.spell;
180    let effect_index = lookup.effect.effect_index;
181
182    if !spell_effect_is(lookup.effect_type(), SpellEffectKind::ApplyAura) {
183        return Ok(None);
184    }
185
186    let coef = lookup.coefficient();
187    let rating_effect = |rating: RatingType| -> Result<Option<BuffEffect>, EngineError> {
188        let budget = match item_budget {
189            Some(b) => b.rating,
190            None => rating_stat_budget(data)?,
191        };
192
193        Ok(Some(rating_buff_effect(lookup, rating, coef * budget)?))
194    };
195
196    match aura_subtype_kind(data.effect_aura(idx, effect_index)) {
197        Some(AuraSubtypeKind::ModRating) if coef > 0.0 => {
198            let misc = data.effect_misc_value_0(idx, effect_index);
199
200            rating_effect(rating_type_from_mask(misc).unwrap_or(RatingType::Crit))
201        }
202        Some(AuraSubtypeKind::PeriodicDummy) if coef > 0.0 => rating_effect(RatingType::Crit),
203        Some(AuraSubtypeKind::ModStat)
204            if coef > 0.0
205                && data.effect_misc_value_0(idx, effect_index)
206                    != StatModifierKind::Stamina as i32 =>
207        {
208            let budget = match item_budget {
209                Some(b) => b.stat,
210                None => primary_stat_budget(data)?,
211            };
212
213            Ok(Some(BuffEffect::PrimaryStat(coef * budget)))
214        }
215        Some(AuraSubtypeKind::ModTotalStatPercentage) => {
216            let pct = data.base_points(idx, effect_index);
217
218            Ok((pct > 0.0).then_some(BuffEffect::PrimaryStatPercent(pct)))
219        }
220        _ => Ok(None),
221    }
222}
223
224fn consumable_stat_effects(
225    data: &ResolvedGameData,
226    spell_id: u32,
227    item_budget: Option<ItemBudget>,
228    stats: &CombatStats,
229) -> Result<[Option<BuffEffect>; MAX_AURA_BUFF_EFFECTS], EngineError> {
230    let idx = SpellIdx::from_raw(spell_id);
231    let rating_effects = (1..=CONSUMABLE_MAX_EFFECT)
232        .filter(|&effect_index| {
233            spell_effect_is(
234                data.effect_type(idx, effect_index),
235                SpellEffectKind::ApplyAura,
236            ) && aura_subtype_kind(data.effect_aura(idx, effect_index))
237                == Some(AuraSubtypeKind::ModRating)
238                && data.coefficient(idx, effect_index).abs() > f64::EPSILON
239        })
240        .filter_map(|effect_index| {
241            let rating = rating_type_from_mask(data.effect_misc_value_0(idx, effect_index))?;
242
243            Some((effect_index, rating, data.coefficient(idx, effect_index)))
244        })
245        .collect::<Vec<_>>();
246
247    if rating_effects.iter().any(|(_, _, coef)| *coef < 0.0) {
248        return selected_secondary_effects(data, spell_id, item_budget, stats, &rating_effects);
249    }
250
251    let mut effects = [None; MAX_AURA_BUFF_EFFECTS];
252
253    for effect_index in 1..=CONSUMABLE_MAX_EFFECT {
254        if let Some(effect) = consumable_effect_at(
255            wowlab_engine_domain::dbc::EffectLookup::new(
256                data,
257                wowlab_types::sim::EffectRef::new(idx, effect_index),
258            ),
259            item_budget,
260        )? {
261            try_push_effect(&mut effects, effect).map_err(|e| {
262                // #t(rust_alloc_in_loop) format! only fires on the error path (effect-array overflow)
263                EngineError::spec_construction(format!(
264                    "consumable spell {spell_id} has more stat effects than MAX_AURA_BUFF_EFFECTS: {e:?}"
265                ))
266            })?;
267        }
268    }
269
270    if effects[0].is_none() && !data.is_empty() {
271        return Err(EngineError::spec_construction(format!(
272            "consumable spell {spell_id} has no modelled stat effects (aura kinds 189/226/29/137)"
273        )));
274    }
275
276    Ok(effects)
277}
278
279fn selected_secondary_effects(
280    data: &ResolvedGameData,
281    spell_id: u32,
282    item_budget: Option<ItemBudget>,
283    stats: &CombatStats,
284    rating_effects: &[(u8, RatingType, f64)],
285) -> Result<[Option<BuffEffect>; MAX_AURA_BUFF_EFFECTS], EngineError> {
286    // SimC's `util::highest_stat`/`lowest_stat` rank by RATING, not by percent
287    // points (per-stat divisors and the mastery coefficient would reorder a
288    // percent comparison). Estimate each stat's rating by dividing its
289    // above-base percent by the stat's per-rating conversion.
290    let base_stat = |rating| match rating {
291        RatingType::Crit => wowlab_engine_domain::constants::BASE_CRIT_CHANCE * HUNDRED,
292        RatingType::Mastery => stats::BASE_MASTERY_POINTS,
293        _ => 0.0,
294    };
295    let estimated_rating = |rating: RatingType| -> Result<f64, EngineError> {
296        let per_hundred = rating_percent_points(data, rating, HUNDRED)?;
297
298        if per_hundred.abs() < f64::EPSILON {
299            return Ok(0.0);
300        }
301
302        let value = match rating {
303            RatingType::Crit => stats.crit_chance,
304            RatingType::Haste => stats.haste,
305            RatingType::Mastery => stats.mastery,
306            RatingType::Versatility => stats.versatility,
307            RatingType::Leech | RatingType::Avoidance | RatingType::Speed => 0.0,
308        };
309
310        Ok((value - base_stat(rating)).max(0.0) * HUNDRED / per_hundred)
311    };
312    let mut ranked = Vec::with_capacity(rating_effects.len());
313
314    for entry in rating_effects {
315        ranked.push((entry, estimated_rating(entry.1)?));
316    }
317
318    let positive = ranked
319        .iter()
320        .filter(|((_, _, coef), _)| *coef > 0.0)
321        .max_by(|(_, left), (_, right)| left.total_cmp(right));
322    let negative = ranked
323        .iter()
324        .filter(|((_, _, coef), _)| *coef < 0.0)
325        .min_by(|(_, left), (_, right)| left.total_cmp(right));
326    let selected = [
327        positive.map(|(entry, rating)| (**entry, *rating)),
328        negative.map(|(entry, rating)| (**entry, *rating)),
329    ];
330    let budget =
331        item_budget.map_or_else(|| rating_stat_budget(data), |budget| Ok(budget.rating))?;
332    let mut effects = [None; MAX_AURA_BUFF_EFFECTS];
333
334    for ((effect_index, rating, coefficient), current_rating) in selected.into_iter().flatten() {
335        // SimC converts the total rating through curve 21024. Values below the
336        // curve's first point clamp to zero, so a selected-stat penalty cannot
337        // turn a zero (or smaller-than-penalty) rating into a negative percent.
338        let amount = clamp_rating_penalty(current_rating, coefficient * budget);
339        let effect = rating_buff_effect(
340            wowlab_engine_domain::dbc::EffectLookup::new(
341                data,
342                wowlab_types::sim::EffectRef::new(SpellIdx::from_raw(spell_id), effect_index),
343            ),
344            rating,
345            amount,
346        )?;
347
348        try_push_effect(&mut effects, effect)
349        .map_err(|error| {
350            // #t(rust_alloc_in_loop) allocation occurs only while returning a bounded construction error.
351            EngineError::spec_construction(format!(
352                "consumable spell {spell_id} selected more stat effects than MAX_AURA_BUFF_EFFECTS: {error:?}"
353            ))
354        })?;
355    }
356
357    Ok(effects)
358}
359
360fn food_effects(
361    data: &ResolvedGameData,
362    food: &FoodBuff,
363) -> Result<[Option<BuffEffect>; MAX_AURA_BUFF_EFFECTS], EngineError> {
364    let coef = data.coefficient(SpellIdx::from_raw(food.coeff_spell_id), food.coeff_effect);
365
366    if coef <= 0.0 && !data.is_empty() {
367        return Err(EngineError::spec_construction(format!(
368            "food coefficient missing: spell {} effect {} for '{}'",
369            food.coeff_spell_id, food.coeff_effect, food.name
370        )));
371    }
372
373    let mut effects = [None; MAX_AURA_BUFF_EFFECTS];
374
375    effects[0] = Some(BuffEffect::PrimaryStat(
376        coef * primary_stat_budget(data)? * food.amount_multiplier,
377    ));
378
379    Ok(effects)
380}
381
382fn register_buff_aura(
383    built: &mut BuiltCombatSystem,
384    data: &ResolvedGameData,
385    spell_id: u32,
386    name: &str,
387    fallback_duration_ms: u32,
388    build_effects: impl FnOnce() -> Result<[Option<BuffEffect>; MAX_AURA_BUFF_EFFECTS], EngineError>,
389) -> Result<(), EngineError> {
390    let key = AuraKey::new(
391        AuraIdx(spell_id),
392        wowlab_types::sim::ActorId::Player,
393        wowlab_types::sim::ActorId::Player,
394        AuraOn::Player,
395    );
396    let effects = build_effects()?;
397
398    if let Some(existing_local) = built.state.aura_local(spell_id) {
399        if !built.patch_aura(existing_local, |aura| aura.effects = effects) {
400            return Err(EngineError::spec_construction(format!(
401                "registered aura {spell_id} has no definition"
402            )));
403        }
404
405        built.set_precombat_aura(existing_local, true);
406
407        built.buffer.ensure_aura_slot(key);
408
409        return Ok(());
410    }
411
412    let duration_ms = match data
413        .aura_duration_ms(SpellIdx::from_raw(spell_id))
414        .unwrap_or(0)
415    {
416        0 => {
417            tracing::warn!(
418                spell_id,
419                fallback_duration_ms,
420                "aura duration missing; using fallback"
421            );
422
423            fallback_duration_ms
424        }
425        d => d,
426    };
427
428    let aura = AuraData::simple_player_buff(spell_id, 0, duration_ms, effects);
429    let local = built.register_aura(name, aura)?;
430
431    built.push_precombat_aura(local);
432
433    Ok(())
434}
435
436fn register_consumable(
437    built: &mut BuiltCombatSystem,
438    data: &ResolvedGameData,
439    slot: ConsumableSlot,
440) -> Result<(), EngineError> {
441    let consumables = data.consumable_spells();
442
443    match slot {
444        ConsumableSlot::Potion => match &consumables.potion {
445            None => Ok(()),
446            Some(buff) => {
447                let effects = consumable_stat_effects(
448                    data,
449                    buff.spell_id,
450                    buff.item_budget,
451                    built.base_combat_stats(),
452                )?;
453
454                register_buff_aura(
455                    built,
456                    data,
457                    buff.spell_id,
458                    &buff.name,
459                    POTION_FALLBACK_DURATION_MS,
460                    || Ok(effects),
461                )
462            }
463        },
464        ConsumableSlot::Flask => match &consumables.flask {
465            None => Ok(()),
466            Some(buff) => {
467                let effects = consumable_stat_effects(
468                    data,
469                    buff.spell_id,
470                    buff.item_budget,
471                    built.base_combat_stats(),
472                )?;
473
474                register_buff_aura(
475                    built,
476                    data,
477                    buff.spell_id,
478                    &buff.name,
479                    CONSUMABLE_FALLBACK_DURATION_MS,
480                    || Ok(effects),
481                )
482            }
483        },
484        ConsumableSlot::AugmentRune => match &consumables.augment_rune {
485            None => Ok(()),
486            Some(buff) => {
487                let effects = consumable_stat_effects(
488                    data,
489                    buff.spell_id,
490                    buff.item_budget,
491                    built.base_combat_stats(),
492                )?;
493
494                register_buff_aura(
495                    built,
496                    data,
497                    buff.spell_id,
498                    &buff.name,
499                    CONSUMABLE_FALLBACK_DURATION_MS,
500                    || Ok(effects),
501                )
502            }
503        },
504        ConsumableSlot::Food => match &consumables.food {
505            None => Ok(()),
506            Some(food) => register_buff_aura(
507                built,
508                data,
509                food.spell_id,
510                &food.name,
511                CONSUMABLE_FALLBACK_DURATION_MS,
512                || food_effects(data, food),
513            ),
514        },
515    }
516}
517
518pub(crate) fn register_precombat_buff(
519    built: &mut BuiltCombatSystem,
520    data: &ResolvedGameData,
521    spec: &PrecombatBuffSpec,
522) -> Result<(), EngineError> {
523    match spec.source {
524        BuffSource::Fixed {
525            spell_id,
526            name,
527            fallback_duration_ms,
528            build_effects,
529        } => {
530            let family_lockout = BLOODLUST_FAMILY
531                .iter()
532                .find_map(|(family_spell_id, lockout)| {
533                    (*family_spell_id == spell_id).then_some(*lockout)
534                });
535
536            if family_lockout.is_some() {
537                for (_, lockout_aura_id) in BLOODLUST_FAMILY {
538                    register_buff_aura(
539                        built,
540                        data,
541                        lockout_aura_id,
542                        "Bloodlust Lockout",
543                        BLOODLUST_LOCKOUT_DURATION_MS,
544                        || Ok([None; MAX_AURA_BUFF_EFFECTS]),
545                    )?;
546
547                    if let Some(local) = built.state.aura_local(lockout_aura_id) {
548                        built.set_precombat_aura(local, false);
549                    }
550                }
551            }
552
553            register_buff_aura(built, data, spell_id, name, fallback_duration_ms, || {
554                build_effects(data)
555            })?;
556
557            if let Some(lockout_aura_id) = family_lockout {
558                let local = built.state.aura_local(spell_id).ok_or_else(|| {
559                    EngineError::spec_construction("Bloodlust-family aura registration was lost")
560                })?;
561
562                if !built.patch_aura(local, |aura| {
563                    aura.application_excluded_auras =
564                        BLOODLUST_FAMILY.map(|(_, family_lockout)| family_lockout);
565                    aura.application_followup_aura_id = lockout_aura_id;
566                }) {
567                    return Err(EngineError::spec_construction(
568                        "Bloodlust-family aura registration has no definition",
569                    ));
570                }
571            }
572
573            Ok(())
574        }
575        BuffSource::Consumable(slot) => register_consumable(built, data, slot),
576    }
577}
578
579/// Register every spec in `specs` onto `built`, then the data-selected food buff; idempotent per spell id.
580///
581/// # Errors
582///
583/// Returns an engine construction error when a selected buff cannot be resolved or registered.
584pub(crate) fn register_precombat_buffs(
585    built: &mut BuiltCombatSystem,
586    data: &ResolvedGameData,
587    specs: &[PrecombatBuffSpec],
588) -> Result<(), EngineError> {
589    for spec in specs {
590        register_precombat_buff(built, data, spec)?;
591    }
592
593    register_consumable(built, data, ConsumableSlot::Food)
594}
595
596#[expect(
597    clippy::unnecessary_wraps,
598    reason = "precombat effect factories share a fallible callback signature"
599)]
600fn bloodlust_effects(
601    data: &ResolvedGameData,
602) -> Result<[Option<BuffEffect>; MAX_AURA_BUFF_EFFECTS], EngineError> {
603    let haste = data.base_points(
604        SpellIdx::from_raw(BLOODLUST_SPELL_ID),
605        BLOODLUST_HASTE_EFFECT,
606    );
607    let mut effects = [None; MAX_AURA_BUFF_EFFECTS];
608
609    effects[0] = Some(BuffEffect::HasteMult(haste));
610
611    Ok(effects)
612}
613
614fn external_buff_effects(
615    data: &ResolvedGameData,
616    spell_id: u32,
617) -> Result<[Option<BuffEffect>; MAX_AURA_BUFF_EFFECTS], EngineError> {
618    let spell = SpellIdx::from_raw(spell_id);
619    let mut effects = [None; MAX_AURA_BUFF_EFFECTS];
620
621    for effect_index in 1..=data.max_effect_index(spell) {
622        let subtype = aura_subtype_kind(data.effect_aura(spell, effect_index));
623        let effect = match subtype {
624            Some(AuraSubtypeKind::HasteAll | AuraSubtypeKind::ModSpellHastePercent) => {
625                Some(BuffEffect::HasteMult(data.base_points(spell, effect_index)))
626            }
627            Some(AuraSubtypeKind::ModAllCritChance | AuraSubtypeKind::ModSpellCritChance) => {
628                Some(BuffEffect::Crit(data.base_points(spell, effect_index)))
629            }
630            Some(AuraSubtypeKind::ModMasteryPercent) => {
631                Some(BuffEffect::Mastery(data.base_points(spell, effect_index)))
632            }
633            _ => None,
634        };
635
636        if let Some(effect) = effect {
637            try_push_effect(&mut effects, effect).map_err(|error| {
638                // #t(rust_alloc_in_loop) allocation occurs only while returning a bounded construction error.
639                EngineError::spec_construction(format!(
640                    "external buff {spell_id} exceeds MAX_AURA_BUFF_EFFECTS: {error:?}"
641                ))
642            })?;
643        }
644    }
645
646    Ok(effects)
647}
648
649/// Register run-configured external buffs without making them precombat auras.
650///
651/// # Errors
652///
653/// Returns a construction error when DBC effects or registry capacity are invalid.
654pub(crate) fn register_external_buffs(
655    built: &mut BuiltCombatSystem,
656    data: &ResolvedGameData,
657    configs: &[wowlab_engine_ports::ExternalBuffConfig],
658) -> Result<(), EngineError> {
659    for config in configs {
660        register_buff_aura(
661            built,
662            data,
663            config.spell_id,
664            "External Buff",
665            wowlab_types::numeric::f64_to_u32_saturating_round(
666                config.schedule.duration * wowlab_types::constants::MS_PER_SECOND,
667            ),
668            || external_buff_effects(data, config.spell_id),
669        )?;
670
671        if let Some(local) = built.state.aura_local(config.spell_id) {
672            built.set_precombat_aura(local, false);
673        }
674    }
675
676    Ok(())
677}
678
679/// Bloodlust (2825) precombat-buff spec.
680pub(crate) const fn bloodlust_spec() -> PrecombatBuffSpec {
681    PrecombatBuffSpec::fixed(
682        BLOODLUST_SPELL_ID,
683        "Bloodlust",
684        BLOODLUST_DURATION_MS,
685        bloodlust_effects,
686    )
687}
688
689/// The sim's selected potion prepot.
690#[must_use]
691pub(crate) const fn tempered_potion_spec() -> PrecombatBuffSpec {
692    PrecombatBuffSpec::consumable(ConsumableSlot::Potion)
693}
694
695/// The sim's selected flask.
696#[must_use]
697pub(crate) const fn flask_spec() -> PrecombatBuffSpec {
698    PrecombatBuffSpec::consumable(ConsumableSlot::Flask)
699}
700
701/// The sim's selected augment rune.
702#[must_use]
703pub(crate) const fn augment_rune_spec() -> PrecombatBuffSpec {
704    PrecombatBuffSpec::consumable(ConsumableSlot::AugmentRune)
705}
706
707#[cfg(test)]
708#[expect(
709    clippy::float_cmp,
710    reason = "buff registration tests assert exact authored fixture values"
711)]
712mod tests;