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        ) && let Some(bonuses) = data.bonuses.get(&bonus.value_0)
114        {
115            expanded.extend(bonuses.iter());
116        }
117    }
118
119    all_bonuses.extend(expanded);
120
121    all_bonuses.sort_by_key(|b| b.order_index);
122
123    all_bonuses
124}
125
126// #t(fn: rust_cyclomatic_complexity, rust_max_fn_lines) two exhaustive 21-variant matches (sub-passes A and B) inflate both metrics
127pub(crate) fn resolve_item_level(
128    bonuses: &[&ItemBonusFlat],
129    base: i32,
130    item_bonus_ids: &[i32],
131    data: &ItemScalingData,
132    drop_level: Option<i32>,
133) -> ItemLevelResolution {
134    let mut item_level = base;
135    let mut has_midnight_scaling = false;
136    let mut applied = Vec::new();
137
138    for bonus in bonuses {
139        match BonusType::try_from(bonus.bonus_type) {
140            Ok(BonusType::SquishCurve) => {
141                let Some(curve_value) =
142                    interpolate_curve(data, bonus.value_0, f64::from(bonus.value_1))
143                else {
144                    continue;
145                };
146                let mut level = f64_to_i32_saturating_round(curve_value);
147
148                if bonus.value_2 == 1
149                    && let Some(curve) = latest_squish_curve(data)
150                    && let Some(squished) = interpolate_curve(data, curve, f64::from(level))
151                {
152                    level = f64_to_i32_saturating_round(squished);
153                }
154
155                item_level = level;
156                has_midnight_scaling = true;
157                applied.push(AppliedBonus {
158                    bonus_list_id: bonus.parent_item_bonus_list_id,
159                    bonus_type: bonus.bonus_type,
160                    description: format!("Item level {item_level} (squish curve)"),
161                });
162            }
163            Ok(BonusType::ScaleConfig) => {
164                let Some(cfg) = data.item_scaling_configs.get(&bonus.value_0) else {
165                    continue;
166                };
167                let Some(offset) = data.item_offset_curves.get(&cfg.item_offset_curve_id) else {
168                    continue;
169                };
170                let squish_curve = (bonus.value_1 != 0
171                    && cfg.required_level <= MAX_SQUISH_REQUIRED_LEVEL)
172                    .then(|| squish_curve_for_era(data, cfg.item_squish_era_id))
173                    .flatten();
174                let curve_id = squish_curve.unwrap_or(offset.curve_id);
175                let Some(curve_value) =
176                    interpolate_curve(data, curve_id, f64::from(cfg.item_level))
177                else {
178                    continue;
179                };
180
181                item_level = f64_to_i32_saturating_round(curve_value) + offset.offset;
182                has_midnight_scaling = true;
183                applied.push(AppliedBonus {
184                    bonus_list_id: bonus.parent_item_bonus_list_id,
185                    bonus_type: bonus.bonus_type,
186                    description: format!(
187                        "Item level {} (scale config {})",
188                        item_level, bonus.value_0
189                    ),
190                });
191            }
192            Ok(BonusType::ScaleConfig2) => {
193                let Some(cfg) = data.item_scaling_configs.get(&bonus.value_0) else {
194                    continue;
195                };
196                let Some(offset) = data.item_offset_curves.get(&cfg.item_offset_curve_id) else {
197                    continue;
198                };
199                let x = drop_level.unwrap_or(cfg.item_level);
200                let Some(curve_value) = interpolate_curve(data, offset.curve_id, f64::from(x))
201                else {
202                    continue;
203                };
204
205                item_level = f64_to_i32_saturating_round(curve_value) + offset.offset;
206                has_midnight_scaling = true;
207                applied.push(AppliedBonus {
208                    bonus_list_id: bonus.parent_item_bonus_list_id,
209                    bonus_type: bonus.bonus_type,
210                    description: format!(
211                        "Item level {} (scale config 2 {})",
212                        item_level, bonus.value_0
213                    ),
214                });
215            }
216            Ok(BonusType::CraftingQuality) => {
217                item_level += bonus.value_0;
218                applied.push(AppliedBonus {
219                    bonus_list_id: bonus.parent_item_bonus_list_id,
220                    bonus_type: bonus.bonus_type,
221                    description: format!("Item level {:+} (crafting quality)", bonus.value_0),
222                });
223            }
224            Ok(BonusType::PostSquishItemLevel) => {
225                if has_midnight_scaling {
226                    item_level += bonus.value_0;
227                    applied.push(AppliedBonus {
228                        bonus_list_id: bonus.parent_item_bonus_list_id,
229                        bonus_type: bonus.bonus_type,
230                        description: format!("Item level {:+} (post squish)", bonus.value_0),
231                    });
232                }
233            }
234            Ok(
235                BonusType::Ilevel
236                | BonusType::SetIlevel2
237                | BonusType::Mod
238                | BonusType::Scaling
239                | BonusType::Scaling2
240                | BonusType::ApplyBonus
241                | BonusType::Quality
242                | BonusType::Desc
243                | BonusType::Suffix
244                | BonusType::Socket
245                | BonusType::ReqLevel
246                | BonusType::SetIlevel
247                | BonusType::AddRank
248                | BonusType::AddItemEffect
249                | BonusType::ModItemStat
250                | BonusType::IlevelInPvp,
251            )
252            | Err(_) => {}
253        }
254    }
255
256    let ilevel_disabled = has_midnight_scaling
257        || item_bonus_ids
258            .iter()
259            .any(|id| ILEVEL_DISABLE_BONUS_IDS.contains(id));
260
261    if !ilevel_disabled {
262        for bonus in bonuses {
263            match BonusType::try_from(bonus.bonus_type) {
264                Ok(BonusType::Ilevel) => {
265                    item_level += bonus.value_0;
266                    applied.push(AppliedBonus {
267                        bonus_list_id: bonus.parent_item_bonus_list_id,
268                        bonus_type: bonus.bonus_type,
269                        description: format!("Item level {:+}", bonus.value_0),
270                    });
271                }
272                Ok(BonusType::SetIlevel2) => {
273                    item_level = bonus.value_0;
274                    applied.push(AppliedBonus {
275                        bonus_list_id: bonus.parent_item_bonus_list_id,
276                        bonus_type: bonus.bonus_type,
277                        description: format!("Item level set to {}", bonus.value_0),
278                    });
279                }
280                Ok(
281                    BonusType::Mod
282                    | BonusType::Quality
283                    | BonusType::Desc
284                    | BonusType::Suffix
285                    | BonusType::Socket
286                    | BonusType::ReqLevel
287                    | BonusType::Scaling
288                    | BonusType::Scaling2
289                    | BonusType::SetIlevel
290                    | BonusType::AddRank
291                    | BonusType::AddItemEffect
292                    | BonusType::ModItemStat
293                    | BonusType::IlevelInPvp
294                    | BonusType::SquishCurve
295                    | BonusType::ScaleConfig
296                    | BonusType::ApplyBonus
297                    | BonusType::ScaleConfig2
298                    | BonusType::CraftingQuality
299                    | BonusType::PostSquishItemLevel,
300                )
301                | Err(_) => {}
302            }
303        }
304    }
305
306    ItemLevelResolution {
307        item_level,
308        applied,
309    }
310}
311
312/// Return a human-readable description for an `ItemBonus` type id.
313pub fn get_bonus_description(bonus_type: i32) -> &'static str {
314    BonusType::try_from(bonus_type).map_or("Unknown", BonusType::name)
315}
316
317#[cfg(test)]
318mod tests;