Skip to main content

wowlab_engine_content/
raid_buffs.rs

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