wowlab_engine_application/
weapon.rs1use 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}