1use 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
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;
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#[derive(Clone, Copy, Debug)]
145pub(crate) struct PrecombatBuffSpec {
146 source: BuffSource,
147}
148
149impl PrecombatBuffSpec {
150 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 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 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 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 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
579pub(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 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
649pub(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
679pub(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#[must_use]
691pub(crate) const fn tempered_potion_spec() -> PrecombatBuffSpec {
692 PrecombatBuffSpec::consumable(ConsumableSlot::Potion)
693}
694
695#[must_use]
697pub(crate) const fn flask_spec() -> PrecombatBuffSpec {
698 PrecombatBuffSpec::consumable(ConsumableSlot::Flask)
699}
700
701#[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;