Skip to main content

wowlab_engine_application/gear/
items.rs

1//! Equipped-item resolution and final gear-stat assembly.
2
3use wowlab_engine_domain::{
4    character_base_stats,
5    dbc::{ItemClass, PassiveStatMods},
6    gear::{
7        MATCHING_ARMOR_SLOTS, apply_resolved_item_stats, class_armor_subclass,
8        crit_damage_bonus_from_effectiveness, crit_effectiveness_with_unique_gems,
9        rating_multiplier_slot,
10    },
11    stats::{CombatStats, PrimaryStats, Ratings, default_stats, primary_stat_for_spec, recompute},
12};
13use wowlab_engine_gamedata::{ResolvedCurves, ResolvedGameTables};
14use wowlab_engine_ports::{DataResolver, DynDataResolver, EngineError, EquippedItem};
15use wowlab_types::{
16    constants::HUNDRED,
17    data::{ItemDataFlat, ItemSetInfo, ResolvedItem, WeaponStats},
18    game::{Attribute, GearEntry, GearSlot, RaceId, SpecId},
19    sim::{FastMap, FastSet},
20};
21
22use super::enchants::{
23    StatAccumulator, StatScalingContext, apply_enchantment_stats, apply_gem_stats,
24    race_crit_effectiveness_mult,
25};
26use crate::weapon::resolve_weapon_for_slot;
27
28#[derive(Debug)]
29pub(crate) struct GearResult {
30    pub stats: CombatStats,
31    pub equipped_items: Vec<EquippedItem>,
32    pub set_bonus_auras: Vec<u32>,
33    pub main_hand: WeaponStats,
34    pub off_hand: Option<WeaponStats>,
35}
36
37impl GearResult {
38    fn empty() -> Self {
39        Self {
40            stats: default_stats(),
41            equipped_items: Vec::new(),
42            set_bonus_auras: Vec::new(),
43            main_hand: WeaponStats::default(),
44            off_hand: None,
45        }
46    }
47}
48
49struct GearBuildState {
50    create_primary: PrimaryStats,
51    primary: PrimaryStats,
52    ratings: Ratings,
53    equipped_items: Vec<EquippedItem>,
54    set_counts: FastMap<i32, u32>,
55    set_infos: FastMap<i32, ItemSetInfo>,
56    crit_effectiveness_mult: f64,
57    unique_gem_colors: FastSet<String>,
58    has_crit_effectiveness_meta: bool,
59    main_hand: WeaponStats,
60    off_hand: Option<WeaponStats>,
61    matching_armor: bool,
62}
63
64impl GearBuildState {
65    async fn new(context: &StatScalingContext<'_>, race: RaceId) -> Self {
66        let create_primary = character_base_stats(context.spec.class(), race, context.level);
67
68        Self {
69            primary: create_primary.clone(),
70            create_primary,
71            ratings: Ratings::default(),
72            equipped_items: Vec::new(),
73            set_counts: FastMap::default(),
74            set_infos: FastMap::default(),
75            crit_effectiveness_mult: race_crit_effectiveness_mult(
76                race,
77                context.spec.class(),
78                context.resolver,
79            )
80            .await,
81            unique_gem_colors: FastSet::default(),
82            has_crit_effectiveness_meta: false,
83            main_hand: WeaponStats::default(),
84            off_hand: None,
85            matching_armor: true,
86        }
87    }
88}
89
90pub(crate) async fn bench_gear_result(
91    extra_item_id: Option<u32>,
92    resolver: &DynDataResolver<'_>,
93) -> Result<GearResult, EngineError> {
94    let mut equipped_items: Vec<EquippedItem> = Vec::new();
95
96    if let Some(id) = extra_item_id {
97        let item = resolver
98            .get_item(wowlab_types::numeric::u32_to_i32_saturating(id))
99            .await
100            .map_err(|e| {
101                EngineError::spec_construction(format!("failed to resolve bench item {id}: {e}"))
102            })?;
103
104        equipped_items.push(EquippedItem {
105            slot: GearSlot::Trinket1,
106            item_id: id,
107            item_level: item.item_level,
108            quality: item.quality,
109            enchant_id: None,
110            effects: item.effects,
111        });
112    }
113
114    Ok(GearResult {
115        stats: default_stats(),
116        equipped_items,
117        set_bonus_auras: Vec::new(),
118        main_hand: WeaponStats::default(),
119        off_hand: None,
120    })
121}
122
123pub(crate) async fn compute_stats_from_gear(
124    spec: SpecId,
125    gear: &[GearEntry],
126    stat_passives: &PassiveStatMods,
127    level: u32,
128    race: RaceId,
129    resolver: &DynDataResolver<'_>,
130) -> Result<GearResult, EngineError> {
131    if gear.is_empty() {
132        return Ok(GearResult::empty());
133    }
134
135    let scaling_data = resolver.get_scaling_data().await.map_err(|e| {
136        EngineError::spec_construction(format!("failed to resolve scaling data: {e}"))
137    })?;
138    let curves = ResolvedCurves::from_scaling(&scaling_data.curve_points);
139    let tables = ResolvedGameTables::from_scaling(&scaling_data);
140    let context = StatScalingContext {
141        spec,
142        level,
143        resolver,
144        scaling_data: &scaling_data,
145        tables: &tables,
146    };
147    let mut state = GearBuildState::new(&context, race).await;
148
149    for entry in gear {
150        process_gear_entry(entry, &context, &mut state).await?;
151    }
152
153    finalize_gear_result(state, stat_passives, &context, &curves)
154}
155
156async fn process_gear_entry(
157    entry: &GearEntry,
158    context: &StatScalingContext<'_>,
159    state: &mut GearBuildState,
160) -> Result<(), EngineError> {
161    let item = context
162        .resolver
163        .get_item(wowlab_types::numeric::u32_to_i32_saturating(entry.id))
164        .await
165        .map_err(|e| {
166            EngineError::spec_construction(format!("failed to resolve gear item {}: {e}", entry.id))
167        })?;
168
169    update_armor_and_set_state(entry, &item, context, state);
170
171    let bonus_ids: Vec<i32> = entry
172        .bonus_ids
173        .as_ref()
174        .map(|ids| {
175            ids.iter()
176                .map(|&id| wowlab_types::numeric::u32_to_i32_saturating(id))
177                .collect()
178        })
179        .unwrap_or_default();
180    let resolved = wowlab_parsers::resolve_item(
181        &item,
182        &bonus_ids,
183        context.scaling_data,
184        None,
185        None,
186        entry
187            .ilevel
188            .map(wowlab_types::numeric::u32_to_i32_saturating),
189    );
190
191    apply_item_stats(entry, &resolved, context, state);
192    apply_socket_and_enchant_stats(entry, context, state).await?;
193    record_equipped_item(entry, &resolved, state);
194
195    record_weapon(entry, &resolved, context, state).await
196}
197
198fn update_armor_and_set_state(
199    entry: &GearEntry,
200    item: &ItemDataFlat,
201    context: &StatScalingContext<'_>,
202    state: &mut GearBuildState,
203) {
204    if MATCHING_ARMOR_SLOTS.contains(&entry.slot)
205        && (ItemClass::try_from(item.class_id).ok() != Some(ItemClass::Armor)
206            || item.subclass_id != class_armor_subclass(context.spec.class()) as i32)
207    {
208        state.matching_armor = false;
209    }
210
211    if item.item_set_id > 0 {
212        *state.set_counts.entry(item.item_set_id).or_default() += 1;
213
214        if let Some(info) = &item.set_info {
215            state
216                .set_infos
217                .entry(item.item_set_id)
218                .or_insert_with(|| info.clone());
219        }
220    }
221}
222
223fn apply_item_stats(
224    entry: &GearEntry,
225    resolved: &ResolvedItem,
226    context: &StatScalingContext<'_>,
227    state: &mut GearBuildState,
228) {
229    let rating_mult = rating_multiplier_slot(resolved.inventory_type).map_or(1.0, |slot| {
230        wowlab_engine_domain::stats::rating_multiplier_at_ilvl(
231            context.tables,
232            slot,
233            resolved.item_level,
234        )
235    });
236
237    apply_resolved_item_stats(
238        resolved,
239        entry.crafted_stats.as_deref(),
240        rating_mult,
241        &mut state.primary,
242        &mut state.ratings,
243        context.spec,
244    );
245}
246
247async fn apply_socket_and_enchant_stats(
248    entry: &GearEntry,
249    context: &StatScalingContext<'_>,
250    state: &mut GearBuildState,
251) -> Result<(), EngineError> {
252    let mut stats = StatAccumulator {
253        primary: &mut state.primary,
254        ratings: &mut state.ratings,
255        crit_effectiveness_mult: &mut state.crit_effectiveness_mult,
256        has_crit_effectiveness_meta: &mut state.has_crit_effectiveness_meta,
257        unique_gem_colors: &mut state.unique_gem_colors,
258    };
259
260    if let Some(enchant_id) = entry.enchant_id {
261        apply_enchantment_stats(
262            wowlab_types::numeric::u32_to_i32_saturating(enchant_id),
263            &mut stats,
264            context,
265        )
266        .await?;
267    }
268
269    if let Some(gem_ids) = &entry.gem_ids {
270        apply_gem_stats(gem_ids, &mut stats, context).await?;
271    }
272
273    Ok(())
274}
275
276fn record_equipped_item(entry: &GearEntry, resolved: &ResolvedItem, state: &mut GearBuildState) {
277    if !resolved.effects.is_empty()
278        || (matches!(entry.slot, GearSlot::MainHand | GearSlot::OffHand)
279            && entry.enchant_id.is_some())
280    {
281        state.equipped_items.push(equipped_item(entry, resolved));
282    }
283}
284
285async fn record_weapon(
286    entry: &GearEntry,
287    resolved: &ResolvedItem,
288    context: &StatScalingContext<'_>,
289    state: &mut GearBuildState,
290) -> Result<(), EngineError> {
291    if !matches!(entry.slot, GearSlot::MainHand | GearSlot::OffHand) {
292        return Ok(());
293    }
294
295    let spec_is_caster = primary_stat_for_spec(context.spec) == Attribute::Intellect;
296    let Some(stats) = resolve_weapon_for_slot(context.resolver, resolved, spec_is_caster).await?
297    else {
298        return Ok(());
299    };
300
301    match entry.slot {
302        GearSlot::MainHand => state.main_hand = stats,
303        GearSlot::OffHand => state.off_hand = Some(stats),
304        _ => {}
305    }
306
307    Ok(())
308}
309
310fn finalize_gear_result(
311    mut state: GearBuildState,
312    stat_passives: &PassiveStatMods,
313    context: &StatScalingContext<'_>,
314    curves: &ResolvedCurves,
315) -> Result<GearResult, EngineError> {
316    let set_bonus_auras =
317        collect_set_bonus_auras(context.spec, &state.set_counts, &state.set_infos);
318
319    apply_matching_armor_specialization(
320        &mut state.primary,
321        context.spec,
322        stat_passives,
323        state.matching_armor,
324    );
325
326    if state.has_crit_effectiveness_meta {
327        state.crit_effectiveness_mult = crit_effectiveness_with_unique_gems(
328            state.crit_effectiveness_mult,
329            state.unique_gem_colors.len(),
330        );
331    }
332
333    let mut stats = recompute(
334        &state.primary,
335        &state.ratings,
336        stat_passives,
337        context.spec,
338        context.tables,
339        context.level,
340        curves,
341    )
342    .map_err(|e| {
343        EngineError::spec_construction(format!("failed to compute combat stats from gear: {e}"))
344    })?;
345
346    apply_derived_stats(&mut stats, &state, stat_passives);
347
348    Ok(GearResult {
349        stats,
350        equipped_items: state.equipped_items,
351        set_bonus_auras,
352        main_hand: state.main_hand,
353        off_hand: state.off_hand,
354    })
355}
356
357fn apply_derived_stats(
358    stats: &mut CombatStats,
359    state: &GearBuildState,
360    stat_passives: &PassiveStatMods,
361) {
362    stats.crit_damage_bonus = crit_damage_bonus_from_effectiveness(state.crit_effectiveness_mult);
363    stats.positive_strength_bonus = (state.primary.strength - state.create_primary.strength)
364        .max(0.0)
365        * stat_passives.strength_mult;
366    stats.positive_agility_bonus = (state.primary.agility - state.create_primary.agility).max(0.0)
367        * stat_passives.agility_mult;
368    stats.positive_stamina_bonus = (state.primary.stamina - state.create_primary.stamina).max(0.0)
369        * stat_passives.stamina_mult;
370    stats.positive_intellect_bonus = (state.primary.intellect - state.create_primary.intellect)
371        .max(0.0)
372        * stat_passives.intellect_mult;
373}
374
375pub(super) fn apply_matching_armor_specialization(
376    primary: &mut PrimaryStats,
377    spec: SpecId,
378    stat_passives: &PassiveStatMods,
379    matching_armor: bool,
380) {
381    if !matching_armor {
382        return;
383    }
384
385    let attribute = primary_stat_for_spec(spec);
386    let percent = stat_passives.matching_armor.percent_for(attribute);
387
388    primary.set(
389        attribute,
390        primary.get(attribute) * (1.0 + percent / HUNDRED),
391    );
392}
393
394fn equipped_item(entry: &GearEntry, resolved: &ResolvedItem) -> EquippedItem {
395    EquippedItem {
396        slot: entry.slot,
397        item_id: entry.id,
398        item_level: resolved.item_level,
399        quality: resolved.quality,
400        enchant_id: entry.enchant_id,
401        effects: resolved.effects.clone(),
402    }
403}
404
405fn collect_set_bonus_auras(
406    spec: SpecId,
407    set_counts: &FastMap<i32, u32>,
408    set_infos: &FastMap<i32, ItemSetInfo>,
409) -> Vec<u32> {
410    let wow_spec_id = wowlab_types::numeric::u32_to_i32_saturating(spec.wow_spec_id());
411    let mut set_bonus_auras: Vec<u32> = Vec::new();
412
413    for (&set_id, &count) in set_counts {
414        if let Some(info) = set_infos.get(&set_id) {
415            for bonus in &info.bonuses {
416                if count >= wowlab_types::numeric::i32_to_u32_nonnegative(bonus.threshold)
417                    && (bonus.spec_id == 0 || bonus.spec_id == wow_spec_id)
418                {
419                    set_bonus_auras.push(wowlab_types::numeric::i32_to_u32_nonnegative(
420                        bonus.spell_id,
421                    ));
422                }
423            }
424        }
425    }
426
427    set_bonus_auras
428}