1use 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#[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 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 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 #[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}