Skip to main content

wowlab_engine_application/gear/
enchants.rs

1//! Gem, enchantment, and racial critical-effectiveness resolution.
2
3use wowlab_engine_domain::{
4    dbc::{
5        AuraSubtypeKind, EnchantEffectKind, EnchantTriggerSemantic, SpellEffectSemanticExt,
6        enchant_effect_is, enchant_trigger_semantic, enchantment_stat_value,
7    },
8    gear::{apply_item_stat, crit_effectiveness_with_percent, gem_color_key},
9    stats::{PrimaryStats, Ratings},
10};
11use wowlab_engine_gamedata::ResolvedGameTables;
12use wowlab_engine_ports::{DataResolver, DynDataResolver, EngineError, SpellId};
13use wowlab_types::{
14    data::ItemScalingData,
15    game::{ClassId, RaceId, SpecId},
16    sim::FastSet,
17};
18
19use crate::game_data::resolve_race_passive_spells;
20
21pub(super) struct StatAccumulator<'a> {
22    pub primary: &'a mut PrimaryStats,
23    pub ratings: &'a mut Ratings,
24    pub crit_effectiveness_mult: &'a mut f64,
25    pub has_crit_effectiveness_meta: &'a mut bool,
26    pub unique_gem_colors: &'a mut FastSet<String>,
27}
28
29pub(super) struct StatScalingContext<'a> {
30    pub spec: SpecId,
31    pub level: u32,
32    pub tables: &'a ResolvedGameTables,
33    pub resolver: &'a DynDataResolver<'a>,
34    pub scaling_data: &'a ItemScalingData,
35}
36
37/// Gems resolve through `item.gem_properties` → `GemProperties.enchant_id` → enchantment stats.
38pub(super) async fn apply_gem_stats(
39    gem_ids: &[u32],
40    stats: &mut StatAccumulator<'_>,
41    scaling: &StatScalingContext<'_>,
42) -> Result<(), EngineError> {
43    let mut missing_properties = Vec::with_capacity(gem_ids.len());
44
45    // #t(block: rust_alloc_in_loop) format! only executes on error path, immediately returns
46    for &gem_id in gem_ids {
47        let gem = scaling
48            .resolver
49            .get_item(wowlab_types::numeric::u32_to_i32_saturating(gem_id))
50            .await
51            .map_err(|e| {
52                EngineError::spec_construction(format!("failed to resolve gem item {gem_id}: {e}"))
53            })?;
54
55        if gem.gem_properties <= 0 {
56            continue;
57        }
58
59        if let Some(color) = gem_color_key(&gem.name) {
60            // #t(rust_alloc_in_loop) the owned color must outlive this iteration's resolved gem.
61            stats.unique_gem_colors.insert(color.to_string());
62        }
63
64        let Some(props) = scaling.scaling_data.gem_properties.get(&gem.gem_properties) else {
65            missing_properties.push((gem_id, gem.gem_properties));
66            continue;
67        };
68
69        apply_enchantment_stats(props.enchant_id, stats, scaling).await?;
70    }
71
72    if !missing_properties.is_empty() {
73        tracing::warn!(
74            target: "wowlab::gear",
75            ?missing_properties,
76            "gem properties rows missing; gem stats dropped"
77        );
78    }
79
80    Ok(())
81}
82
83pub(super) async fn apply_enchantment_stats(
84    enchant_id: i32,
85    stats: &mut StatAccumulator<'_>,
86    scaling: &StatScalingContext<'_>,
87) -> Result<(), EngineError> {
88    let enchant = scaling
89        .resolver
90        .get_enchantment(enchant_id)
91        .await
92        .map_err(|e| {
93            EngineError::spec_construction(format!(
94                "failed to resolve enchantment {enchant_id}: {e}"
95            ))
96        })?;
97
98    for effect in &enchant.effects {
99        if enchant_effect_is(effect.effect_type, EnchantEffectKind::Stat) {
100            if let Some(value) = enchantment_stat_value(
101                effect,
102                scaling.tables,
103                enchant.scaling_class,
104                enchant.max_level,
105                scaling.level,
106            ) {
107                apply_item_stat(
108                    stats.primary,
109                    stats.ratings,
110                    scaling.spec,
111                    effect.effect_arg,
112                    value,
113                );
114            }
115        } else if enchant_effect_is(effect.effect_type, EnchantEffectKind::TriggerSpell)
116            && effect.effect_arg > 0
117        {
118            if enchant_trigger_semantic(effect.effect_arg)
119                == Some(EnchantTriggerSemantic::CritEffectivenessPerUniqueGemColor)
120            {
121                *stats.has_crit_effectiveness_meta = true;
122                continue;
123            }
124
125            let passive = scaling
126                .resolver
127                .get_spell(SpellId::new(effect.effect_arg))
128                .await?;
129
130            *stats.crit_effectiveness_mult = passive
131                .effects
132                .iter()
133                .filter(|spell_effect| {
134                    spell_effect.aura_is(AuraSubtypeKind::ModCritDamageMultiplier)
135                })
136                .fold(*stats.crit_effectiveness_mult, |multiplier, aura| {
137                    crit_effectiveness_with_percent(multiplier, aura.base_points)
138                });
139        }
140    }
141
142    Ok(())
143}
144
145/// Folds the race's passive critical-effectiveness auras, such as Might of the Mountain and Brawn.
146pub(super) async fn race_crit_effectiveness_mult(
147    race: RaceId,
148    class: ClassId,
149    resolver: &DynDataResolver<'_>,
150) -> f64 {
151    let mut mult = 1.0;
152
153    // #t(block: rust_async_loop_no_yield) folds already-resolved rows; every await happened above
154    for spell in resolve_race_passive_spells(resolver, Some(race), class).await {
155        for aura in spell
156            .effects
157            .iter()
158            .filter(|effect| effect.aura_is(AuraSubtypeKind::ModCritDamageMultiplier))
159        {
160            mult = crit_effectiveness_with_percent(mult, aura.base_points);
161        }
162    }
163
164    mult
165}