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 ) && 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 #[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}