1use 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
23pub(crate) const SATED_SPELL_ID: u32 = 57_724;
25pub(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#[derive(Clone, Copy, Debug)]
147pub(crate) struct PrecombatBuffSpec {
148 source: BuffSource,
149}
150
151impl PrecombatBuffSpec {
152 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 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 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 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 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
696pub(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 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
766pub(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
796pub(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#[must_use]
808pub(crate) const fn tempered_potion_spec() -> PrecombatBuffSpec {
809 PrecombatBuffSpec::consumable(ConsumableSlot::Potion)
810}
811
812#[must_use]
814pub(crate) const fn flask_spec() -> PrecombatBuffSpec {
815 PrecombatBuffSpec::consumable(ConsumableSlot::Flask)
816}
817
818#[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;