Skip to main content

wowlab_parsers/parsers/scaling/
resolve.rs

1// #t(file: rust_alloc_in_loop) bonus resolution builds per-bonus diagnostic strings
2
3//! Unified item resolution shared by the sim and the tooltip.
4
5use wowlab_types::{
6    constants::MAX_PLAYER_LEVEL,
7    data::{
8        AppliedBonus, ItemDataFlat, ItemEffect, ItemQuality, ItemScalingData, ResolvedItem,
9        ResolvedSocket, ResolvedStat,
10    },
11    numeric::f64_to_i32_saturating_round,
12    sim::IntMap,
13};
14
15use super::{
16    bonus::{ALLOC_MULTIPLIER, BonusType, collect_bonuses, resolve_item_level},
17    curve::interpolate_curve,
18    stats::{get_stat_budget, get_stat_name, inventory_type_to_slot_index},
19};
20
21const CRAFTED_PLACEHOLDER_STAT_IDS: [i32; 2] = [24, 25];
22
23struct ParsedItem {
24    item_level: i32,
25    quality: i32,
26    required_level: i32,
27    crafting_quality: i32,
28    stat_allocs: Vec<(i32, i32)>,
29    sockets: Vec<i32>,
30    effects: Vec<ItemEffect>,
31    applied: Vec<AppliedBonus>,
32}
33
34// docref:start items-scaling-resolve-item
35/// Resolve an item's full effective state from its bonus IDs (the `weapon` field is left for the sim).
36// #t(fn: rust_cyclomatic_complexity) one exhaustive 21-variant match over every BonusType
37// #t(fn: rust_max_fn_lines) the exhaustive parsed-mutation pass and assembly live in one function
38#[must_use]
39pub fn resolve_item(
40    base: &ItemDataFlat,
41    bonus_ids: &[i32],
42    scaling_data: &ItemScalingData,
43    player_level: Option<i32>,
44    drop_level: Option<i32>,
45    forced_item_level: Option<i32>,
46) -> ResolvedItem {
47    // docref:end items-scaling-resolve-item
48    let player_level = player_level.unwrap_or_else(|| i32::from(MAX_PLAYER_LEVEL));
49    let slot_index = inventory_type_to_slot_index(base.inventory_type);
50
51    let bonuses = collect_bonuses(bonus_ids, scaling_data);
52    let level = resolve_item_level(
53        &bonuses,
54        base.item_level,
55        bonus_ids,
56        scaling_data,
57        drop_level,
58    );
59    // An explicit `ilevel=` overrides bonus-derived scaling.
60    let item_level = forced_item_level.unwrap_or(level.item_level);
61
62    let mut parsed = ParsedItem {
63        item_level,
64        quality: base.quality,
65        required_level: base.required_level,
66        crafting_quality: 0,
67        stat_allocs: base.stats.iter().map(|s| (s.stat_type, s.value)).collect(),
68        sockets: base.sockets.clone(),
69        effects: base.effects.clone(),
70        applied: level.applied,
71    };
72
73    for bonus in &bonuses {
74        match BonusType::try_from(bonus.bonus_type) {
75            Ok(BonusType::Quality) => {
76                parsed.quality = bonus.value_0;
77                parsed.applied.push(AppliedBonus {
78                    bonus_list_id: bonus.parent_item_bonus_list_id,
79                    bonus_type: bonus.bonus_type,
80                    description: format!("Quality set to {}", bonus.value_0),
81                });
82            }
83            Ok(BonusType::Socket) => {
84                for _ in 0..bonus.value_0.max(0) {
85                    parsed.sockets.push(bonus.value_1);
86                }
87
88                parsed.applied.push(AppliedBonus {
89                    bonus_list_id: bonus.parent_item_bonus_list_id,
90                    bonus_type: bonus.bonus_type,
91                    description: format!("+{} socket(s) of color {}", bonus.value_0, bonus.value_1),
92                });
93            }
94            Ok(BonusType::ReqLevel) => {
95                parsed.required_level += bonus.value_0;
96                parsed.applied.push(AppliedBonus {
97                    bonus_list_id: bonus.parent_item_bonus_list_id,
98                    bonus_type: bonus.bonus_type,
99                    description: format!("Required level {:+}", bonus.value_0),
100                });
101            }
102            Ok(BonusType::ModItemStat) => {
103                for slot in &mut parsed.stat_allocs {
104                    if CRAFTED_PLACEHOLDER_STAT_IDS.contains(&slot.0) {
105                        slot.0 = bonus.value_0;
106                    }
107                }
108
109                parsed.applied.push(AppliedBonus {
110                    bonus_list_id: bonus.parent_item_bonus_list_id,
111                    bonus_type: bonus.bonus_type,
112                    description: format!("Crafted stat -> {}", get_stat_name(bonus.value_0)),
113                });
114            }
115            Ok(BonusType::AddItemEffect) => {
116                parsed.effects.push(ItemEffect {
117                    spell_id: 0,
118                    trigger_type: 0,
119                    charges: 0,
120                    cooldown: 0,
121                    category_cooldown: 0,
122                });
123                parsed.applied.push(AppliedBonus {
124                    bonus_list_id: bonus.parent_item_bonus_list_id,
125                    bonus_type: bonus.bonus_type,
126                    description: format!("Add item effect {} (data deferred)", bonus.value_0),
127                });
128            }
129            Ok(BonusType::CraftingQuality) => {
130                parsed.crafting_quality = bonus.value_2 + 1;
131                parsed.applied.push(AppliedBonus {
132                    bonus_list_id: bonus.parent_item_bonus_list_id,
133                    bonus_type: bonus.bonus_type,
134                    description: format!("Crafting quality tier {}", parsed.crafting_quality),
135                });
136            }
137            Ok(BonusType::Mod) => {
138                match parsed
139                    .stat_allocs
140                    .iter_mut()
141                    .find(|(stat_type, _)| *stat_type == bonus.value_0)
142                {
143                    Some(slot) => slot.1 += bonus.value_1,
144                    None => parsed.stat_allocs.push((bonus.value_0, bonus.value_1)),
145                }
146            }
147            Ok(
148                BonusType::Ilevel
149                | BonusType::SetIlevel2
150                | BonusType::SquishCurve
151                | BonusType::ScaleConfig
152                | BonusType::ApplyBonus
153                | BonusType::ScaleConfig2
154                | BonusType::PostSquishItemLevel
155                | BonusType::Scaling
156                | BonusType::Scaling2
157                | BonusType::Desc
158                | BonusType::Suffix
159                | BonusType::SetIlevel
160                | BonusType::AddRank
161                | BonusType::IlevelInPvp,
162            )
163            | Err(_) => {}
164        }
165    }
166
167    let budget = if let Some(quality) = ItemQuality::from_dbc(parsed.quality) {
168        if let Some(budget) = get_stat_budget(scaling_data, parsed.item_level, quality, slot_index)
169        {
170            budget
171        } else {
172            tracing::warn!(
173                item_id = base.id,
174                item_level = parsed.item_level,
175                quality = parsed.quality,
176                "item stat allocation has no RandPropPoints budget; allocated stats skipped"
177            );
178
179            0.0
180        }
181    } else {
182        tracing::warn!(
183            item_id = base.id,
184            quality = parsed.quality,
185            "item has unsupported DBC quality; allocated stats skipped"
186        );
187
188        0.0
189    };
190
191    let mut stats_map: IntMap<i32, f64> = IntMap::default();
192
193    for (stat_type, alloc) in &parsed.stat_allocs {
194        let value = f64::from(*alloc) * budget * ALLOC_MULTIPLIER;
195
196        *stats_map.entry(*stat_type).or_default() += value;
197    }
198
199    for bonus in &bonuses {
200        if matches!(
201            BonusType::try_from(bonus.bonus_type),
202            Ok(BonusType::Scaling | BonusType::Scaling2)
203        ) {
204            if let Some(scaled_value) =
205                interpolate_curve(scaling_data, bonus.value_0, f64::from(player_level))
206            {
207                let stat_type = bonus.value_1;
208
209                if stat_type > 0 {
210                    *stats_map.entry(stat_type).or_default() += scaled_value;
211                    parsed.applied.push(AppliedBonus {
212                        bonus_list_id: bonus.parent_item_bonus_list_id,
213                        bonus_type: bonus.bonus_type,
214                        description: format!(
215                            "+{} {} (scaled)",
216                            f64_to_i32_saturating_round(scaled_value),
217                            get_stat_name(stat_type)
218                        ),
219                    });
220                }
221            }
222        }
223    }
224
225    let stats: Vec<ResolvedStat> = stats_map
226        .into_iter()
227        .map(|(stat_type, value)| ResolvedStat {
228            stat_type,
229            stat_name: get_stat_name(stat_type).to_string(),
230            value: f64_to_i32_saturating_round(value),
231        })
232        .filter(|s| s.value != 0)
233        .collect();
234
235    let sockets: Vec<ResolvedSocket> = parsed
236        .sockets
237        .iter()
238        .map(|&color| ResolvedSocket { color })
239        .collect();
240
241    ResolvedItem {
242        id: base.id,
243        name: base.name.to_string(),
244        file_name: base.file_name.to_string(),
245        description: base.description.clone(),
246        base_item_level: base.item_level,
247        item_level: parsed.item_level,
248        quality: parsed.quality,
249        required_level: parsed.required_level,
250        crafting_quality: parsed.crafting_quality,
251        inventory_type: base.inventory_type,
252        class_id: base.class_id,
253        subclass_id: base.subclass_id,
254        binding: base.binding,
255        speed: base.speed,
256        dmg_variance: base.dmg_variance,
257        classification: base.classification.clone(),
258        stats,
259        sockets,
260        socket_bonus_enchant_id: base.socket_bonus_enchant_id,
261        effects: parsed.effects,
262        item_set_id: base.item_set_id,
263        set_info: base.set_info.clone(),
264        weapon: None,
265        applied_bonuses: parsed.applied,
266    }
267}
268
269#[cfg(test)]
270mod resolve_tests {
271    use googletest::prelude::*;
272    use wowlab_types::data::{ItemBonusFlat, ItemStat, RandPropPointsFlat};
273
274    use super::*;
275
276    fn bonus_list(
277        data: &mut ItemScalingData,
278        list_id: i32,
279        bonus_type: i32,
280        order_index: i32,
281        value_0: i32,
282        value_1: i32,
283        value_2: i32,
284    ) {
285        data.bonuses
286            .entry(list_id)
287            .or_default()
288            .push(ItemBonusFlat {
289                id: list_id * 100 + order_index,
290                parent_item_bonus_list_id: list_id,
291                bonus_type,
292                value_0,
293                value_1,
294                value_2,
295                value_3: 0,
296                order_index,
297            });
298    }
299
300    fn budget_row(data: &mut ItemScalingData, ilvl: i32, superior: f64, epic: f64) {
301        data.rand_prop_points.insert(
302            ilvl,
303            RandPropPointsFlat {
304                id: ilvl,
305                superior_f_0: superior,
306                epic_f_0: epic,
307                ..Default::default()
308            },
309        );
310    }
311
312    pub(super) fn base_item(
313        item_level: i32,
314        quality: i32,
315        inventory_type: i32,
316        stats: Vec<ItemStat>,
317    ) -> ItemDataFlat {
318        ItemDataFlat {
319            item_level,
320            quality,
321            inventory_type,
322            stats,
323            ..Default::default()
324        }
325    }
326
327    #[gtest]
328    fn test_resolve_item_quality_override() -> Result<()> {
329        let mut data = ItemScalingData::default();
330
331        bonus_list(&mut data, 1, 3, 0, 4, 0, 0);
332        budget_row(&mut data, 100, 1000.0, 2000.0);
333
334        let base = base_item(
335            100,
336            3,
337            1,
338            vec![ItemStat {
339                stat_type: 3,
340                value: 10000,
341            }],
342        );
343
344        let rare = resolve_item(&base, &[], &data, None, None, None);
345        let epic = resolve_item(&base, &[1], &data, None, None, None);
346
347        verify_that!(rare.quality, eq(3))?;
348        verify_that!(epic.quality, eq(4))?;
349        let rare_val = rare
350            .stats
351            .iter()
352            .find(|s| s.stat_type == 3)
353            .or_fail()?
354            .value;
355        let epic_val = epic
356            .stats
357            .iter()
358            .find(|s| s.stat_type == 3)
359            .or_fail()?
360            .value;
361
362        verify_that!(rare_val, eq(1000))?;
363        verify_that!(epic_val, eq(2000))?;
364
365        Ok(())
366    }
367
368    #[gtest]
369    fn test_resolve_item_sockets() -> Result<()> {
370        let mut data = ItemScalingData::default();
371
372        bonus_list(&mut data, 1, 6, 0, 2, 5, 0);
373        budget_row(&mut data, 100, 1000.0, 2000.0);
374
375        let mut base = base_item(100, 4, 1, vec![]);
376
377        base.sockets = vec![1];
378
379        let resolved = resolve_item(&base, &[1], &data, None, None, None);
380
381        verify_that!(resolved.sockets.len(), eq(3))?;
382        verify_that!(resolved.sockets[1].color, eq(5))?;
383        verify_that!(resolved.sockets[2].color, eq(5))?;
384
385        Ok(())
386    }
387
388    #[gtest]
389    fn test_resolve_item_req_level() -> Result<()> {
390        let mut data = ItemScalingData::default();
391
392        bonus_list(&mut data, 1, 8, 0, 10, 0, 0);
393        budget_row(&mut data, 100, 1000.0, 2000.0);
394
395        let mut base = base_item(100, 4, 1, vec![]);
396
397        base.required_level = 60;
398
399        let resolved = resolve_item(&base, &[1], &data, None, None, None);
400
401        verify_that!(resolved.required_level, eq(70))?;
402
403        Ok(())
404    }
405
406    #[gtest]
407    fn test_resolve_item_crafting_quality() -> Result<()> {
408        let mut data = ItemScalingData::default();
409
410        bonus_list(&mut data, 1, 52, 0, 5, 0, 2);
411        budget_row(&mut data, 105, 1000.0, 2000.0);
412
413        let base = base_item(100, 4, 1, vec![]);
414
415        let resolved = resolve_item(&base, &[1], &data, None, None, None);
416
417        verify_that!(resolved.item_level, eq(105))?;
418        verify_that!(resolved.crafting_quality, eq(3))?;
419
420        Ok(())
421    }
422}
423
424#[cfg(test)]
425mod resolve_parity_tests {
426    use googletest::prelude::*;
427    use wowlab_types::data::{
428        CurveFlat, CurvePointFlat, ItemBonusFlat, ItemOffsetCurveFlat, ItemScalingConfigFlat,
429        ItemStat, RandPropPointsFlat,
430    };
431
432    use super::{ItemScalingData, resolve_item, resolve_tests::base_item};
433
434    /// Locks `resolve_item` output for the no-mutation case via the Midnight fixture: config 228 → curve 88583 → ilvl 289.
435    #[gtest]
436    fn test_resolve_item_parity() -> Result<()> {
437        let mut data = ItemScalingData::default();
438
439        data.bonuses.insert(
440            12806,
441            vec![ItemBonusFlat {
442                id: 1,
443                parent_item_bonus_list_id: 12806,
444                bonus_type: 49,
445                value_0: 228,
446                value_1: 0,
447                value_2: 0,
448                value_3: 0,
449                order_index: 0,
450            }],
451        );
452
453        data.item_scaling_configs.insert(
454            228,
455            ItemScalingConfigFlat {
456                id: 228,
457                item_offset_curve_id: 47,
458                item_level: 289,
459                required_level: 90,
460                item_squish_era_id: 2,
461                flags: 0,
462            },
463        );
464
465        data.item_offset_curves.insert(
466            47,
467            ItemOffsetCurveFlat {
468                id: 47,
469                curve_id: 88583,
470                offset: 0,
471            },
472        );
473
474        data.curves.insert(
475            88583,
476            CurveFlat {
477                id: 88583,
478                curve_type: 0,
479                flags: 0,
480            },
481        );
482
483        data.curve_points.insert(
484            88583,
485            vec![
486                CurvePointFlat {
487                    id: 1,
488                    curve_id: 88583,
489                    order_index: 0,
490                    pos_0: 0.0,
491                    pos_1: 0.0,
492                    pos_pre_squish_0: 0.0,
493                    pos_pre_squish_1: 0.0,
494                },
495                CurvePointFlat {
496                    id: 2,
497                    curve_id: 88583,
498                    order_index: 1,
499                    pos_0: 1300.0,
500                    pos_1: 1300.0,
501                    pos_pre_squish_0: 0.0,
502                    pos_pre_squish_1: 0.0,
503                },
504            ],
505        );
506
507        data.rand_prop_points.insert(
508            289,
509            RandPropPointsFlat {
510                id: 289,
511                epic_f_0: 1000.0,
512                ..Default::default()
513            },
514        );
515
516        let stats = vec![ItemStat {
517            stat_type: 3,
518            value: 5000,
519        }];
520        let base = base_item(197, 4, 1, stats);
521
522        let resolved = resolve_item(&base, &[12806], &data, None, None, None);
523
524        verify_that!(resolved.item_level, eq(289))?;
525        verify_that!(resolved.stats.len(), eq(1))?;
526        let stat = resolved.stats.iter().find(|s| s.stat_type == 3).or_fail()?;
527
528        verify_that!(stat.value, eq(500))?;
529
530        Ok(())
531    }
532}