Skip to main content

wowlab_engine_application/
weapon.rs

1//! Weapon damage scaling and weapon-enchant proc resolution.
2
3use wowlab_engine_domain::dbc::{
4    EnchantEffectKind, classify_enchant_buff_effect, classify_weapon, enchant_effect_is,
5    enchantment_stat_value, resolve_weapon_stats, rppm_crit_scaled, rppm_haste_scaled,
6    spell_scaling_budget,
7};
8use wowlab_engine_gamedata::ResolvedGameTables;
9use wowlab_engine_ports::{
10    DataResolver, DynDataResolver, EnchantmentRow, EngineError, SpellId, WeaponEnchantProc,
11    WeaponEnchantStat,
12};
13use wowlab_types::{
14    data::{ItemDamageScalingFlat, ResolvedItem, WeaponStats},
15    game::{GearEntry, GearSlot},
16};
17
18const MAX_ENCHANT_PROC_CHAIN_DEPTH: u8 = 4;
19
20pub(crate) async fn extract_weapon_enchant_procs(
21    gear: &[GearEntry],
22    tables: &ResolvedGameTables,
23    player_level: u32,
24    resolver: &DynDataResolver<'_>,
25) -> Result<Vec<WeaponEnchantProc>, EngineError> {
26    let mut procs: Vec<WeaponEnchantProc> = Vec::new();
27
28    for entry in gear {
29        if !matches!(entry.slot, GearSlot::MainHand | GearSlot::OffHand) {
30            continue;
31        }
32
33        let Some(enchant_id) = entry.enchant_id else {
34            continue;
35        };
36
37        if let Some(proc) =
38            resolve_single_weapon_enchant(enchant_id, tables, player_level, resolver).await?
39        {
40            procs.push(proc);
41        }
42    }
43
44    Ok(procs)
45}
46
47fn effect0_trigger_target(spell: &wowlab_types::data::SpellDataFlat) -> Option<u32> {
48    spell.effects.iter().find(|e| e.index == 0).and_then(|e| {
49        if e.trigger_spell != 0 {
50            Some(wowlab_types::numeric::i32_to_u32_nonnegative(
51                e.trigger_spell,
52            ))
53        } else if e.misc_value_0 != 0 {
54            Some(wowlab_types::numeric::i32_to_u32_nonnegative(
55                e.misc_value_0,
56            ))
57        } else {
58            None
59        }
60    })
61}
62
63fn linked_spell_value(
64    spell: &wowlab_types::data::SpellDataFlat,
65    tables: &ResolvedGameTables,
66    player_level: u32,
67) -> Option<f64> {
68    let effect = spell.effects.iter().find(|effect| effect.index == 0)?;
69
70    if effect.scaling_class == 0 || effect.coefficient <= 0.0 {
71        return None;
72    }
73
74    let minimum = u32::try_from(spell.min_scaling_level.max(0)).unwrap_or(0);
75    let maximum = u32::try_from(spell.max_scaling_level)
76        .ok()
77        .filter(|maximum| *maximum > 0)
78        .unwrap_or(player_level);
79    let level = player_level.max(minimum).min(maximum);
80    let budget = spell_scaling_budget(tables, effect.scaling_class, level)?;
81
82    Some(budget * f64::from(effect.coefficient))
83}
84
85async fn resolve_single_weapon_enchant(
86    enchant_id: u32,
87    tables: &ResolvedGameTables,
88    player_level: u32,
89    resolver: &DynDataResolver<'_>,
90) -> Result<Option<WeaponEnchantProc>, EngineError> {
91    let enchant: EnchantmentRow = resolver
92        .get_enchantment(wowlab_types::numeric::u32_to_i32_saturating(enchant_id))
93        .await?;
94
95    let Some(trigger_effect) = enchant
96        .effects
97        .iter()
98        .find(|effect| {
99            enchant_effect_is(effect.effect_type, EnchantEffectKind::TriggerSpell)
100                && effect.effect_arg != 0
101        })
102        .copied()
103    else {
104        tracing::warn!(
105            enchant_id,
106            enchant_name = %enchant.name,
107            "no trigger-spell effect on enchantment; skipping"
108        );
109
110        return Ok(None);
111    };
112    let passive_spell_id = wowlab_types::numeric::i32_to_u32_nonnegative(trigger_effect.effect_arg);
113
114    let Some((proc_spell, buff_spell_id, linked_amount)) =
115        resolve_enchant_proc_chain(passive_spell_id, tables, player_level, resolver).await?
116    else {
117        return Ok(None);
118    };
119
120    let rppm = f64::from(proc_spell.rppm_base_rate);
121    let haste_scales = rppm_haste_scaled(&proc_spell.rppm_mods);
122    let crit_scales = rppm_crit_scaled(&proc_spell.rppm_mods);
123
124    let buff = resolver
125        .get_spell(SpellId::new(wowlab_types::numeric::u32_to_i32_saturating(
126            buff_spell_id,
127        )))
128        .await?;
129
130    let buff_duration_ms = wowlab_types::numeric::i32_to_u32_nonnegative(buff.duration);
131
132    let buff_effect = buff.effects.iter().find(|effect| effect.index == 0);
133    let (buff_stat, spell_amount) = buff_effect.map_or((WeaponEnchantStat::None, 0.0), |effect| {
134        classify_enchant_buff_effect(effect.aura, effect.misc_value_0, effect.base_points)
135    });
136    let buff_amount = if spell_amount > 0.0 {
137        spell_amount
138    } else if linked_amount > 0.0 {
139        linked_amount
140    } else {
141        enchantment_stat_value(
142            &trigger_effect,
143            tables,
144            enchant.scaling_class,
145            enchant.max_level,
146            player_level,
147        )
148        .unwrap_or(0.0)
149    };
150
151    if matches!(buff_stat, WeaponEnchantStat::None) || buff_amount <= 0.0 {
152        let (aura_kind, misc_value, base_points, coefficient, variance, effect_scaling_class) =
153            buff_effect.map_or((0, 0, 0.0, 0.0, 0.0, 0), |effect| {
154                (
155                    effect.aura,
156                    effect.misc_value_0,
157                    effect.base_points,
158                    effect.coefficient,
159                    effect.variance,
160                    effect.scaling_class,
161                )
162            });
163
164        return Err(EngineError::spec_construction(format!(
165            "weapon enchant {enchant_id} buff {buff_spell_id} effect 0 did not match a known stat \
166                 bucket or scaled amount; aura={aura_kind}, misc={misc_value}, \
167                 base_points={base_points}, coefficient={coefficient}, variance={variance}, \
168                 effect_scaling_class={effect_scaling_class}, enchant_scaling_class={}, max_level={}, \
169                 trigger_points_min={:?}, trigger_scaling_points={:?}",
170            enchant.scaling_class,
171            enchant.max_level,
172            trigger_effect.points_min,
173            trigger_effect.scaling_points,
174        )));
175    }
176
177    Ok(Some(WeaponEnchantProc {
178        enchantment_id: enchant_id,
179        rppm,
180        haste_scales,
181        crit_scales,
182        buff_spell_id,
183        buff_duration_ms,
184        buff_stat,
185        buff_amount,
186        name: enchant.name,
187    }))
188}
189
190async fn resolve_enchant_proc_chain(
191    passive_spell_id: u32,
192    tables: &ResolvedGameTables,
193    player_level: u32,
194    resolver: &DynDataResolver<'_>,
195) -> Result<Option<(wowlab_types::data::SpellDataFlat, u32, f64)>, EngineError> {
196    let mut proc_spell = resolver
197        .get_spell(SpellId::new(wowlab_types::numeric::u32_to_i32_saturating(
198            passive_spell_id,
199        )))
200        .await?;
201    let mut linked_amount = 0.0;
202
203    for _ in 0..=MAX_ENCHANT_PROC_CHAIN_DEPTH {
204        if let Some(value) = linked_spell_value(&proc_spell, tables, player_level) {
205            linked_amount = value;
206        }
207
208        let Some(target) = effect0_trigger_target(&proc_spell) else {
209            return Ok(None);
210        };
211
212        if proc_spell.rppm_base_rate > 0.0 {
213            return Ok(Some((proc_spell, target, linked_amount)));
214        }
215
216        proc_spell = resolver
217            .get_spell(SpellId::new(wowlab_types::numeric::u32_to_i32_saturating(
218                target,
219            )))
220            .await?;
221    }
222
223    Ok(None)
224}
225
226pub(crate) async fn resolve_weapon_for_slot(
227    resolver: &DynDataResolver<'_>,
228    resolved: &ResolvedItem,
229    spec_is_caster: bool,
230) -> Result<Option<WeaponStats>, EngineError> {
231    let Some(class) = classify_weapon(
232        resolved.inventory_type,
233        resolved.subclass_id,
234        spec_is_caster,
235    ) else {
236        return Ok(None);
237    };
238
239    let weapon_type = class.weapon_type_string();
240    let damage: ItemDamageScalingFlat = resolver
241        .get_item_damage_scaling(resolved.item_level, weapon_type)
242        .await
243        .map_err(|e| {
244            EngineError::spec_construction(format!(
245                "failed to resolve item_damage_scaling for {weapon_type} @ ilvl {}: {e}",
246                resolved.item_level
247            ))
248        })?;
249
250    Ok(resolve_weapon_stats(resolved, &damage, spec_is_caster))
251}
252
253#[cfg(test)]
254mod tests {
255    use googletest::prelude::*;
256    use rstest::rstest;
257    use wowlab_types::data::SpellEffect;
258
259    use super::*;
260
261    fn effect(index: i32, trigger_spell: i32, misc_value_0: i32) -> SpellEffect {
262        SpellEffect {
263            index,
264            trigger_spell,
265            misc_value_0,
266            ..Default::default()
267        }
268    }
269
270    #[gtest]
271    #[rstest]
272    #[case::no_effect_index_0(vec![effect(1, 555, 999)], None)]
273    #[case::trigger_spell_wins(vec![effect(0, 555, 999)], Some(555))]
274    #[case::falls_back_to_misc_when_trigger_zero(vec![effect(0, 0, 777)], Some(777))]
275    #[case::both_zero_is_none(vec![effect(0, 0, 0)], None)]
276    #[case::empty_effects_is_none(vec![], None)]
277    fn effect0_trigger_target_cases(
278        #[case] effects: Vec<SpellEffect>,
279        #[case] expected: Option<u32>,
280    ) -> Result<()> {
281        let spell = wowlab_types::data::SpellDataFlat {
282            effects,
283            ..Default::default()
284        };
285
286        verify_that!(effect0_trigger_target(&spell), eq(expected))
287    }
288}