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        ) && let Some(scaled_value) =
204            interpolate_curve(scaling_data, bonus.value_0, f64::from(player_level))
205        {
206            let stat_type = bonus.value_1;
207
208            if stat_type > 0 {
209                *stats_map.entry(stat_type).or_default() += scaled_value;
210                parsed.applied.push(AppliedBonus {
211                    bonus_list_id: bonus.parent_item_bonus_list_id,
212                    bonus_type: bonus.bonus_type,
213                    description: format!(
214                        "+{} {} (scaled)",
215                        f64_to_i32_saturating_round(scaled_value),
216                        get_stat_name(stat_type)
217                    ),
218                });
219            }
220        }
221    }
222
223    let stats: Vec<ResolvedStat> = stats_map
224        .into_iter()
225        .map(|(stat_type, value)| ResolvedStat {
226            stat_type,
227            stat_name: get_stat_name(stat_type).to_string(),
228            value: f64_to_i32_saturating_round(value),
229        })
230        .filter(|s| s.value != 0)
231        .collect();
232
233    let sockets: Vec<ResolvedSocket> = parsed
234        .sockets
235        .iter()
236        .map(|&color| ResolvedSocket { color })
237        .collect();
238
239    ResolvedItem {
240        id: base.id,
241        name: base.name.to_string(),
242        file_name: base.file_name.to_string(),
243        description: base.description.clone(),
244        base_item_level: base.item_level,
245        item_level: parsed.item_level,
246        quality: parsed.quality,
247        required_level: parsed.required_level,
248        crafting_quality: parsed.crafting_quality,
249        inventory_type: base.inventory_type,
250        class_id: base.class_id,
251        subclass_id: base.subclass_id,
252        binding: base.binding,
253        speed: base.speed,
254        dmg_variance: base.dmg_variance,
255        classification: base.classification.clone(),
256        stats,
257        sockets,
258        socket_bonus_enchant_id: base.socket_bonus_enchant_id,
259        effects: parsed.effects,
260        item_set_id: base.item_set_id,
261        set_info: base.set_info.clone(),
262        weapon: None,
263        applied_bonuses: parsed.applied,
264    }
265}
266
267#[cfg(test)]
268mod resolve_tests {
269    use googletest::prelude::*;
270    use wowlab_types::data::{ItemBonusFlat, ItemStat, RandPropPointsFlat};
271
272    use super::*;
273
274    fn bonus_list(
275        data: &mut ItemScalingData,
276        list_id: i32,
277        bonus_type: i32,
278        order_index: i32,
279        value_0: i32,
280        value_1: i32,
281        value_2: i32,
282    ) {
283        data.bonuses
284            .entry(list_id)
285            .or_default()
286            .push(ItemBonusFlat {
287                id: list_id * 100 + order_index,
288                parent_item_bonus_list_id: list_id,
289                bonus_type,
290                value_0,
291                value_1,
292                value_2,
293                value_3: 0,
294                order_index,
295            });
296    }
297
298    fn budget_row(data: &mut ItemScalingData, ilvl: i32, superior: f64, epic: f64) {
299        data.rand_prop_points.insert(
300            ilvl,
301            RandPropPointsFlat {
302                id: ilvl,
303                superior_f_0: superior,
304                epic_f_0: epic,
305                ..Default::default()
306            },
307        );
308    }
309
310    pub(super) fn base_item(
311        item_level: i32,
312        quality: i32,
313        inventory_type: i32,
314        stats: Vec<ItemStat>,
315    ) -> ItemDataFlat {
316        ItemDataFlat {
317            item_level,
318            quality,
319            inventory_type,
320            stats,
321            ..Default::default()
322        }
323    }
324
325    #[gtest]
326    fn test_resolve_item_quality_override() -> Result<()> {
327        let mut data = ItemScalingData::default();
328
329        bonus_list(&mut data, 1, 3, 0, 4, 0, 0);
330        budget_row(&mut data, 100, 1000.0, 2000.0);
331
332        let base = base_item(
333            100,
334            3,
335            1,
336            vec![ItemStat {
337                stat_type: 3,
338                value: 10000,
339            }],
340        );
341
342        let rare = resolve_item(&base, &[], &data, None, None, None);
343        let epic = resolve_item(&base, &[1], &data, None, None, None);
344
345        verify_that!(rare.quality, eq(3))?;
346        verify_that!(epic.quality, eq(4))?;
347        let rare_val = rare
348            .stats
349            .iter()
350            .find(|s| s.stat_type == 3)
351            .or_fail()?
352            .value;
353        let epic_val = epic
354            .stats
355            .iter()
356            .find(|s| s.stat_type == 3)
357            .or_fail()?
358            .value;
359
360        verify_that!(rare_val, eq(1000))?;
361        verify_that!(epic_val, eq(2000))?;
362
363        Ok(())
364    }
365
366    #[gtest]
367    fn test_resolve_item_sockets() -> Result<()> {
368        let mut data = ItemScalingData::default();
369
370        bonus_list(&mut data, 1, 6, 0, 2, 5, 0);
371        budget_row(&mut data, 100, 1000.0, 2000.0);
372
373        let mut base = base_item(100, 4, 1, vec![]);
374
375        base.sockets = vec![1];
376
377        let resolved = resolve_item(&base, &[1], &data, None, None, None);
378
379        verify_that!(resolved.sockets.len(), eq(3))?;
380        verify_that!(resolved.sockets[1].color, eq(5))?;
381        verify_that!(resolved.sockets[2].color, eq(5))?;
382
383        Ok(())
384    }
385
386    #[gtest]
387    fn test_resolve_item_req_level() -> Result<()> {
388        let mut data = ItemScalingData::default();
389
390        bonus_list(&mut data, 1, 8, 0, 10, 0, 0);
391        budget_row(&mut data, 100, 1000.0, 2000.0);
392
393        let mut base = base_item(100, 4, 1, vec![]);
394
395        base.required_level = 60;
396
397        let resolved = resolve_item(&base, &[1], &data, None, None, None);
398
399        verify_that!(resolved.required_level, eq(70))?;
400
401        Ok(())
402    }
403
404    #[gtest]
405    fn test_resolve_item_crafting_quality() -> Result<()> {
406        let mut data = ItemScalingData::default();
407
408        bonus_list(&mut data, 1, 52, 0, 5, 0, 2);
409        budget_row(&mut data, 105, 1000.0, 2000.0);
410
411        let base = base_item(100, 4, 1, vec![]);
412
413        let resolved = resolve_item(&base, &[1], &data, None, None, None);
414
415        verify_that!(resolved.item_level, eq(105))?;
416        verify_that!(resolved.crafting_quality, eq(3))?;
417
418        Ok(())
419    }
420}
421
422#[cfg(test)]
423mod resolve_parity_tests {
424    use googletest::prelude::*;
425    use wowlab_types::data::{
426        CurveFlat, CurvePointFlat, ItemBonusFlat, ItemOffsetCurveFlat, ItemScalingConfigFlat,
427        ItemStat, RandPropPointsFlat,
428    };
429
430    use super::{ItemScalingData, resolve_item, resolve_tests::base_item};
431
432    /// Locks `resolve_item` output for the no-mutation case via the Midnight fixture: config 228 → curve 88583 → ilvl 289.
433    #[gtest]
434    fn test_resolve_item_parity() -> Result<()> {
435        let mut data = ItemScalingData::default();
436
437        data.bonuses.insert(
438            12806,
439            vec![ItemBonusFlat {
440                id: 1,
441                parent_item_bonus_list_id: 12806,
442                bonus_type: 49,
443                value_0: 228,
444                value_1: 0,
445                value_2: 0,
446                value_3: 0,
447                order_index: 0,
448            }],
449        );
450
451        data.item_scaling_configs.insert(
452            228,
453            ItemScalingConfigFlat {
454                id: 228,
455                item_offset_curve_id: 47,
456                item_level: 289,
457                required_level: 90,
458                item_squish_era_id: 2,
459                flags: 0,
460            },
461        );
462
463        data.item_offset_curves.insert(
464            47,
465            ItemOffsetCurveFlat {
466                id: 47,
467                curve_id: 88583,
468                offset: 0,
469            },
470        );
471
472        data.curves.insert(
473            88583,
474            CurveFlat {
475                id: 88583,
476                curve_type: 0,
477                flags: 0,
478            },
479        );
480
481        data.curve_points.insert(
482            88583,
483            vec![
484                CurvePointFlat {
485                    id: 1,
486                    curve_id: 88583,
487                    order_index: 0,
488                    pos_0: 0.0,
489                    pos_1: 0.0,
490                    pos_pre_squish_0: 0.0,
491                    pos_pre_squish_1: 0.0,
492                },
493                CurvePointFlat {
494                    id: 2,
495                    curve_id: 88583,
496                    order_index: 1,
497                    pos_0: 1300.0,
498                    pos_1: 1300.0,
499                    pos_pre_squish_0: 0.0,
500                    pos_pre_squish_1: 0.0,
501                },
502            ],
503        );
504
505        data.rand_prop_points.insert(
506            289,
507            RandPropPointsFlat {
508                id: 289,
509                epic_f_0: 1000.0,
510                ..Default::default()
511            },
512        );
513
514        let stats = vec![ItemStat {
515            stat_type: 3,
516            value: 5000,
517        }];
518        let base = base_item(197, 4, 1, stats);
519
520        let resolved = resolve_item(&base, &[12806], &data, None, None, None);
521
522        verify_that!(resolved.item_level, eq(289))?;
523        verify_that!(resolved.stats.len(), eq(1))?;
524        let stat = resolved.stats.iter().find(|s| s.stat_type == 3).or_fail()?;
525
526        verify_that!(stat.value, eq(500))?;
527
528        Ok(())
529    }
530}