Skip to main content

wowlab_parsers/parsers/scaling/
bonus.rs

1// #t(file: rust_alloc_in_loop) bonus application builds per-bonus diagnostic strings
2
3//! Applies item bonuses to compute scaled stats from `WoW` DBC data.
4
5use wowlab_types::{
6    data::{AppliedBonus, ItemBonusFlat, ItemScalingData},
7    numeric::f64_to_i32_saturating_round,
8};
9
10use super::curve::interpolate_curve;
11
12const MAX_SQUISH_REQUIRED_LEVEL: i32 = 80;
13
14// Allocation percentages are stored in ten-thousandths.
15pub(crate) const ALLOC_MULTIPLIER: f64 = 0.0001;
16
17const ILEVEL_DISABLE_BONUS_IDS: [i32; 2] = [7215, 7250];
18
19fn squish_curve_for_era(data: &ItemScalingData, era_id: i32) -> Option<i32> {
20    let curve = data.item_squish_eras.get(&era_id)?.curve_id;
21
22    (curve != 0).then_some(curve)
23}
24
25fn latest_squish_curve(data: &ItemScalingData) -> Option<i32> {
26    let latest = data
27        .item_squish_eras
28        .values()
29        .filter(|e| e.curve_id != 0)
30        .max_by_key(|e| e.patch);
31
32    latest.map(|e| e.curve_id)
33}
34
35/// Every `item_bonus_type` Blizzard defines; value indices are our 0-based `value_0..value_3`.
36#[derive(Clone, Copy, Debug, Eq, PartialEq, num_enum::TryFromPrimitive)]
37#[repr(i32)]
38pub(super) enum BonusType {
39    Ilevel = 1,
40    Mod = 2,
41    Quality = 3,
42    Desc = 4,
43    Suffix = 5,
44    Socket = 6,
45    ReqLevel = 8,
46    Scaling = 11,
47    Scaling2 = 13,
48    SetIlevel = 14,
49    AddRank = 17,
50    AddItemEffect = 23,
51    ModItemStat = 25,
52    IlevelInPvp = 36,
53    SetIlevel2 = 42,
54    SquishCurve = 48,
55    ScaleConfig = 49,
56    ApplyBonus = 50,
57    ScaleConfig2 = 51,
58    CraftingQuality = 52,
59    PostSquishItemLevel = 53,
60}
61
62impl BonusType {
63    // #t(fn: rust_cyclomatic_complexity) exhaustive 21-variant label dispatch, one arm per bonus type
64    pub(super) const fn name(self) -> &'static str {
65        match self {
66            BonusType::Ilevel => "Item Level Modifier",
67            BonusType::Mod => "Stat Modifier",
68            BonusType::Quality => "Quality Modifier",
69            BonusType::Desc => "Description",
70            BonusType::Suffix => "Random Suffix",
71            BonusType::Socket => "Socket",
72            BonusType::ReqLevel => "Required Level",
73            BonusType::Scaling => "Scaling Curve",
74            BonusType::Scaling2 => "Scaling Curve (Alt)",
75            BonusType::SetIlevel | BonusType::SetIlevel2 => "Set Item Level",
76            BonusType::AddRank => "Artifact Rank",
77            BonusType::AddItemEffect => "Item Effect",
78            BonusType::ModItemStat => "Modify Stat",
79            BonusType::IlevelInPvp => "PvP Item Level",
80            BonusType::SquishCurve => "Squish Curve",
81            BonusType::ScaleConfig => "Scale Config",
82            BonusType::ApplyBonus => "Apply Bonus List",
83            BonusType::ScaleConfig2 => "Scale Config (Drop Level)",
84            BonusType::CraftingQuality => "Crafting Quality",
85            BonusType::PostSquishItemLevel => "Post-Squish Item Level",
86        }
87    }
88}
89
90pub(crate) struct ItemLevelResolution {
91    pub(crate) item_level: i32,
92    pub(crate) applied: Vec<AppliedBonus>,
93}
94
95pub(crate) fn collect_bonuses<'a>(
96    bonus_ids: &[i32],
97    data: &'a ItemScalingData,
98) -> Vec<&'a ItemBonusFlat> {
99    let mut all_bonuses: Vec<&ItemBonusFlat> = Vec::new();
100
101    for &bonus_id in bonus_ids {
102        if let Some(bonuses) = data.bonuses.get(&bonus_id) {
103            all_bonuses.extend(bonuses.iter());
104        }
105    }
106
107    let mut expanded: Vec<&ItemBonusFlat> = Vec::new();
108
109    for bonus in &all_bonuses {
110        if matches!(
111            BonusType::try_from(bonus.bonus_type),
112            Ok(BonusType::ApplyBonus)
113        ) {
114            if let Some(bonuses) = data.bonuses.get(&bonus.value_0) {
115                expanded.extend(bonuses.iter());
116            }
117        }
118    }
119
120    all_bonuses.extend(expanded);
121
122    all_bonuses.sort_by_key(|b| b.order_index);
123
124    all_bonuses
125}
126
127// #t(fn: rust_cyclomatic_complexity, rust_max_fn_lines) two exhaustive 21-variant matches (sub-passes A and B) inflate both metrics
128pub(crate) fn resolve_item_level(
129    bonuses: &[&ItemBonusFlat],
130    base: i32,
131    item_bonus_ids: &[i32],
132    data: &ItemScalingData,
133    drop_level: Option<i32>,
134) -> ItemLevelResolution {
135    let mut item_level = base;
136    let mut has_midnight_scaling = false;
137    let mut applied = Vec::new();
138
139    for bonus in bonuses {
140        match BonusType::try_from(bonus.bonus_type) {
141            Ok(BonusType::SquishCurve) => {
142                let Some(curve_value) =
143                    interpolate_curve(data, bonus.value_0, f64::from(bonus.value_1))
144                else {
145                    continue;
146                };
147                let mut level = f64_to_i32_saturating_round(curve_value);
148
149                if bonus.value_2 == 1 {
150                    if let Some(curve) = latest_squish_curve(data) {
151                        if let Some(squished) = interpolate_curve(data, curve, f64::from(level)) {
152                            level = f64_to_i32_saturating_round(squished);
153                        }
154                    }
155                }
156
157                item_level = level;
158                has_midnight_scaling = true;
159                applied.push(AppliedBonus {
160                    bonus_list_id: bonus.parent_item_bonus_list_id,
161                    bonus_type: bonus.bonus_type,
162                    description: format!("Item level {item_level} (squish curve)"),
163                });
164            }
165            Ok(BonusType::ScaleConfig) => {
166                let Some(cfg) = data.item_scaling_configs.get(&bonus.value_0) else {
167                    continue;
168                };
169                let Some(offset) = data.item_offset_curves.get(&cfg.item_offset_curve_id) else {
170                    continue;
171                };
172                let squish_curve = (bonus.value_1 != 0
173                    && cfg.required_level <= MAX_SQUISH_REQUIRED_LEVEL)
174                    .then(|| squish_curve_for_era(data, cfg.item_squish_era_id))
175                    .flatten();
176                let curve_id = squish_curve.unwrap_or(offset.curve_id);
177                let Some(curve_value) =
178                    interpolate_curve(data, curve_id, f64::from(cfg.item_level))
179                else {
180                    continue;
181                };
182
183                item_level = f64_to_i32_saturating_round(curve_value) + offset.offset;
184                has_midnight_scaling = true;
185                applied.push(AppliedBonus {
186                    bonus_list_id: bonus.parent_item_bonus_list_id,
187                    bonus_type: bonus.bonus_type,
188                    description: format!(
189                        "Item level {} (scale config {})",
190                        item_level, bonus.value_0
191                    ),
192                });
193            }
194            Ok(BonusType::ScaleConfig2) => {
195                let Some(cfg) = data.item_scaling_configs.get(&bonus.value_0) else {
196                    continue;
197                };
198                let Some(offset) = data.item_offset_curves.get(&cfg.item_offset_curve_id) else {
199                    continue;
200                };
201                let x = drop_level.unwrap_or(cfg.item_level);
202                let Some(curve_value) = interpolate_curve(data, offset.curve_id, f64::from(x))
203                else {
204                    continue;
205                };
206
207                item_level = f64_to_i32_saturating_round(curve_value) + offset.offset;
208                has_midnight_scaling = true;
209                applied.push(AppliedBonus {
210                    bonus_list_id: bonus.parent_item_bonus_list_id,
211                    bonus_type: bonus.bonus_type,
212                    description: format!(
213                        "Item level {} (scale config 2 {})",
214                        item_level, bonus.value_0
215                    ),
216                });
217            }
218            Ok(BonusType::CraftingQuality) => {
219                item_level += bonus.value_0;
220                applied.push(AppliedBonus {
221                    bonus_list_id: bonus.parent_item_bonus_list_id,
222                    bonus_type: bonus.bonus_type,
223                    description: format!("Item level {:+} (crafting quality)", bonus.value_0),
224                });
225            }
226            Ok(BonusType::PostSquishItemLevel) => {
227                if has_midnight_scaling {
228                    item_level += bonus.value_0;
229                    applied.push(AppliedBonus {
230                        bonus_list_id: bonus.parent_item_bonus_list_id,
231                        bonus_type: bonus.bonus_type,
232                        description: format!("Item level {:+} (post squish)", bonus.value_0),
233                    });
234                }
235            }
236            Ok(
237                BonusType::Ilevel
238                | BonusType::SetIlevel2
239                | BonusType::Mod
240                | BonusType::Scaling
241                | BonusType::Scaling2
242                | BonusType::ApplyBonus
243                | BonusType::Quality
244                | BonusType::Desc
245                | BonusType::Suffix
246                | BonusType::Socket
247                | BonusType::ReqLevel
248                | BonusType::SetIlevel
249                | BonusType::AddRank
250                | BonusType::AddItemEffect
251                | BonusType::ModItemStat
252                | BonusType::IlevelInPvp,
253            )
254            | Err(_) => {}
255        }
256    }
257
258    let ilevel_disabled = has_midnight_scaling
259        || item_bonus_ids
260            .iter()
261            .any(|id| ILEVEL_DISABLE_BONUS_IDS.contains(id));
262
263    if !ilevel_disabled {
264        for bonus in bonuses {
265            match BonusType::try_from(bonus.bonus_type) {
266                Ok(BonusType::Ilevel) => {
267                    item_level += bonus.value_0;
268                    applied.push(AppliedBonus {
269                        bonus_list_id: bonus.parent_item_bonus_list_id,
270                        bonus_type: bonus.bonus_type,
271                        description: format!("Item level {:+}", bonus.value_0),
272                    });
273                }
274                Ok(BonusType::SetIlevel2) => {
275                    item_level = bonus.value_0;
276                    applied.push(AppliedBonus {
277                        bonus_list_id: bonus.parent_item_bonus_list_id,
278                        bonus_type: bonus.bonus_type,
279                        description: format!("Item level set to {}", bonus.value_0),
280                    });
281                }
282                Ok(
283                    BonusType::Mod
284                    | BonusType::Quality
285                    | BonusType::Desc
286                    | BonusType::Suffix
287                    | BonusType::Socket
288                    | BonusType::ReqLevel
289                    | BonusType::Scaling
290                    | BonusType::Scaling2
291                    | BonusType::SetIlevel
292                    | BonusType::AddRank
293                    | BonusType::AddItemEffect
294                    | BonusType::ModItemStat
295                    | BonusType::IlevelInPvp
296                    | BonusType::SquishCurve
297                    | BonusType::ScaleConfig
298                    | BonusType::ApplyBonus
299                    | BonusType::ScaleConfig2
300                    | BonusType::CraftingQuality
301                    | BonusType::PostSquishItemLevel,
302                )
303                | Err(_) => {}
304            }
305        }
306    }
307
308    ItemLevelResolution {
309        item_level,
310        applied,
311    }
312}
313
314/// Return a human-readable description for an `ItemBonus` type id.
315pub fn get_bonus_description(bonus_type: i32) -> &'static str {
316    BonusType::try_from(bonus_type).map_or("Unknown", BonusType::name)
317}
318
319#[cfg(test)]
320mod tests;