1use wowlab_engine_domain::{
4 character_base_stats,
5 dbc::{ItemClass, PassiveStatMods},
6 gear::{
7 MATCHING_ARMOR_SLOTS, apply_resolved_item_stats, class_armor_subclass,
8 crit_damage_bonus_from_effectiveness, crit_effectiveness_with_unique_gems,
9 rating_multiplier_slot,
10 },
11 stats::{CombatStats, PrimaryStats, Ratings, default_stats, primary_stat_for_spec, recompute},
12};
13use wowlab_engine_gamedata::{ResolvedCurves, ResolvedGameTables};
14use wowlab_engine_ports::{DataResolver, DynDataResolver, EngineError, EquippedItem};
15use wowlab_types::{
16 constants::HUNDRED,
17 data::{ItemDataFlat, ItemSetInfo, ResolvedItem, WeaponStats},
18 game::{Attribute, GearEntry, GearSlot, RaceId, SpecId},
19 sim::{FastMap, FastSet},
20};
21
22use super::enchants::{
23 StatAccumulator, StatScalingContext, apply_enchantment_stats, apply_gem_stats,
24 race_crit_effectiveness_mult,
25};
26use crate::weapon::resolve_weapon_for_slot;
27
28#[derive(Debug)]
29pub(crate) struct GearResult {
30 pub stats: CombatStats,
31 pub equipped_items: Vec<EquippedItem>,
32 pub set_bonus_auras: Vec<u32>,
33 pub main_hand: WeaponStats,
34 pub off_hand: Option<WeaponStats>,
35}
36
37impl GearResult {
38 fn empty() -> Self {
39 Self {
40 stats: default_stats(),
41 equipped_items: Vec::new(),
42 set_bonus_auras: Vec::new(),
43 main_hand: WeaponStats::default(),
44 off_hand: None,
45 }
46 }
47}
48
49struct GearBuildState {
50 create_primary: PrimaryStats,
51 primary: PrimaryStats,
52 ratings: Ratings,
53 equipped_items: Vec<EquippedItem>,
54 set_counts: FastMap<i32, u32>,
55 set_infos: FastMap<i32, ItemSetInfo>,
56 crit_effectiveness_mult: f64,
57 unique_gem_colors: FastSet<String>,
58 has_crit_effectiveness_meta: bool,
59 main_hand: WeaponStats,
60 off_hand: Option<WeaponStats>,
61 matching_armor: bool,
62}
63
64impl GearBuildState {
65 async fn new(context: &StatScalingContext<'_>, race: RaceId) -> Self {
66 let create_primary = character_base_stats(context.spec.class(), race, context.level);
67
68 Self {
69 primary: create_primary.clone(),
70 create_primary,
71 ratings: Ratings::default(),
72 equipped_items: Vec::new(),
73 set_counts: FastMap::default(),
74 set_infos: FastMap::default(),
75 crit_effectiveness_mult: race_crit_effectiveness_mult(
76 race,
77 context.spec.class(),
78 context.resolver,
79 )
80 .await,
81 unique_gem_colors: FastSet::default(),
82 has_crit_effectiveness_meta: false,
83 main_hand: WeaponStats::default(),
84 off_hand: None,
85 matching_armor: true,
86 }
87 }
88}
89
90pub(crate) async fn bench_gear_result(
91 extra_item_id: Option<u32>,
92 resolver: &DynDataResolver<'_>,
93) -> Result<GearResult, EngineError> {
94 let mut equipped_items: Vec<EquippedItem> = Vec::new();
95
96 if let Some(id) = extra_item_id {
97 let item = resolver
98 .get_item(wowlab_types::numeric::u32_to_i32_saturating(id))
99 .await
100 .map_err(|e| {
101 EngineError::spec_construction(format!("failed to resolve bench item {id}: {e}"))
102 })?;
103
104 equipped_items.push(EquippedItem {
105 slot: GearSlot::Trinket1,
106 item_id: id,
107 item_level: item.item_level,
108 quality: item.quality,
109 enchant_id: None,
110 effects: item.effects,
111 });
112 }
113
114 Ok(GearResult {
115 stats: default_stats(),
116 equipped_items,
117 set_bonus_auras: Vec::new(),
118 main_hand: WeaponStats::default(),
119 off_hand: None,
120 })
121}
122
123pub(crate) async fn compute_stats_from_gear(
124 spec: SpecId,
125 gear: &[GearEntry],
126 stat_passives: &PassiveStatMods,
127 level: u32,
128 race: RaceId,
129 resolver: &DynDataResolver<'_>,
130) -> Result<GearResult, EngineError> {
131 if gear.is_empty() {
132 return Ok(GearResult::empty());
133 }
134
135 let scaling_data = resolver.get_scaling_data().await.map_err(|e| {
136 EngineError::spec_construction(format!("failed to resolve scaling data: {e}"))
137 })?;
138 let curves = ResolvedCurves::from_scaling(&scaling_data.curve_points);
139 let tables = ResolvedGameTables::from_scaling(&scaling_data);
140 let context = StatScalingContext {
141 spec,
142 level,
143 resolver,
144 scaling_data: &scaling_data,
145 tables: &tables,
146 };
147 let mut state = GearBuildState::new(&context, race).await;
148
149 for entry in gear {
150 process_gear_entry(entry, &context, &mut state).await?;
151 }
152
153 finalize_gear_result(state, stat_passives, &context, &curves)
154}
155
156async fn process_gear_entry(
157 entry: &GearEntry,
158 context: &StatScalingContext<'_>,
159 state: &mut GearBuildState,
160) -> Result<(), EngineError> {
161 let item = context
162 .resolver
163 .get_item(wowlab_types::numeric::u32_to_i32_saturating(entry.id))
164 .await
165 .map_err(|e| {
166 EngineError::spec_construction(format!("failed to resolve gear item {}: {e}", entry.id))
167 })?;
168
169 update_armor_and_set_state(entry, &item, context, state);
170
171 let bonus_ids: Vec<i32> = entry
172 .bonus_ids
173 .as_ref()
174 .map(|ids| {
175 ids.iter()
176 .map(|&id| wowlab_types::numeric::u32_to_i32_saturating(id))
177 .collect()
178 })
179 .unwrap_or_default();
180 let resolved = wowlab_parsers::resolve_item(
181 &item,
182 &bonus_ids,
183 context.scaling_data,
184 None,
185 None,
186 entry
187 .ilevel
188 .map(wowlab_types::numeric::u32_to_i32_saturating),
189 );
190
191 apply_item_stats(entry, &resolved, context, state);
192 apply_socket_and_enchant_stats(entry, context, state).await?;
193 record_equipped_item(entry, &resolved, state);
194
195 record_weapon(entry, &resolved, context, state).await
196}
197
198fn update_armor_and_set_state(
199 entry: &GearEntry,
200 item: &ItemDataFlat,
201 context: &StatScalingContext<'_>,
202 state: &mut GearBuildState,
203) {
204 if MATCHING_ARMOR_SLOTS.contains(&entry.slot)
205 && (ItemClass::try_from(item.class_id).ok() != Some(ItemClass::Armor)
206 || item.subclass_id != class_armor_subclass(context.spec.class()) as i32)
207 {
208 state.matching_armor = false;
209 }
210
211 if item.item_set_id > 0 {
212 *state.set_counts.entry(item.item_set_id).or_default() += 1;
213
214 if let Some(info) = &item.set_info {
215 state
216 .set_infos
217 .entry(item.item_set_id)
218 .or_insert_with(|| info.clone());
219 }
220 }
221}
222
223fn apply_item_stats(
224 entry: &GearEntry,
225 resolved: &ResolvedItem,
226 context: &StatScalingContext<'_>,
227 state: &mut GearBuildState,
228) {
229 let rating_mult = rating_multiplier_slot(resolved.inventory_type).map_or(1.0, |slot| {
230 wowlab_engine_domain::stats::rating_multiplier_at_ilvl(
231 context.tables,
232 slot,
233 resolved.item_level,
234 )
235 });
236
237 apply_resolved_item_stats(
238 resolved,
239 entry.crafted_stats.as_deref(),
240 rating_mult,
241 &mut state.primary,
242 &mut state.ratings,
243 context.spec,
244 );
245}
246
247async fn apply_socket_and_enchant_stats(
248 entry: &GearEntry,
249 context: &StatScalingContext<'_>,
250 state: &mut GearBuildState,
251) -> Result<(), EngineError> {
252 let mut stats = StatAccumulator {
253 primary: &mut state.primary,
254 ratings: &mut state.ratings,
255 crit_effectiveness_mult: &mut state.crit_effectiveness_mult,
256 has_crit_effectiveness_meta: &mut state.has_crit_effectiveness_meta,
257 unique_gem_colors: &mut state.unique_gem_colors,
258 };
259
260 if let Some(enchant_id) = entry.enchant_id {
261 apply_enchantment_stats(
262 wowlab_types::numeric::u32_to_i32_saturating(enchant_id),
263 &mut stats,
264 context,
265 )
266 .await?;
267 }
268
269 if let Some(gem_ids) = &entry.gem_ids {
270 apply_gem_stats(gem_ids, &mut stats, context).await?;
271 }
272
273 Ok(())
274}
275
276fn record_equipped_item(entry: &GearEntry, resolved: &ResolvedItem, state: &mut GearBuildState) {
277 if !resolved.effects.is_empty()
278 || (matches!(entry.slot, GearSlot::MainHand | GearSlot::OffHand)
279 && entry.enchant_id.is_some())
280 {
281 state.equipped_items.push(equipped_item(entry, resolved));
282 }
283}
284
285async fn record_weapon(
286 entry: &GearEntry,
287 resolved: &ResolvedItem,
288 context: &StatScalingContext<'_>,
289 state: &mut GearBuildState,
290) -> Result<(), EngineError> {
291 if !matches!(entry.slot, GearSlot::MainHand | GearSlot::OffHand) {
292 return Ok(());
293 }
294
295 let spec_is_caster = primary_stat_for_spec(context.spec) == Attribute::Intellect;
296 let Some(stats) = resolve_weapon_for_slot(context.resolver, resolved, spec_is_caster).await?
297 else {
298 return Ok(());
299 };
300
301 match entry.slot {
302 GearSlot::MainHand => state.main_hand = stats,
303 GearSlot::OffHand => state.off_hand = Some(stats),
304 _ => {}
305 }
306
307 Ok(())
308}
309
310fn finalize_gear_result(
311 mut state: GearBuildState,
312 stat_passives: &PassiveStatMods,
313 context: &StatScalingContext<'_>,
314 curves: &ResolvedCurves,
315) -> Result<GearResult, EngineError> {
316 let set_bonus_auras =
317 collect_set_bonus_auras(context.spec, &state.set_counts, &state.set_infos);
318
319 apply_matching_armor_specialization(
320 &mut state.primary,
321 context.spec,
322 stat_passives,
323 state.matching_armor,
324 );
325
326 if state.has_crit_effectiveness_meta {
327 state.crit_effectiveness_mult = crit_effectiveness_with_unique_gems(
328 state.crit_effectiveness_mult,
329 state.unique_gem_colors.len(),
330 );
331 }
332
333 let mut stats = recompute(
334 &state.primary,
335 &state.ratings,
336 stat_passives,
337 context.spec,
338 context.tables,
339 context.level,
340 curves,
341 )
342 .map_err(|e| {
343 EngineError::spec_construction(format!("failed to compute combat stats from gear: {e}"))
344 })?;
345
346 apply_derived_stats(&mut stats, &state, stat_passives);
347
348 Ok(GearResult {
349 stats,
350 equipped_items: state.equipped_items,
351 set_bonus_auras,
352 main_hand: state.main_hand,
353 off_hand: state.off_hand,
354 })
355}
356
357fn apply_derived_stats(
358 stats: &mut CombatStats,
359 state: &GearBuildState,
360 stat_passives: &PassiveStatMods,
361) {
362 stats.crit_damage_bonus = crit_damage_bonus_from_effectiveness(state.crit_effectiveness_mult);
363 stats.positive_strength_bonus = (state.primary.strength - state.create_primary.strength)
364 .max(0.0)
365 * stat_passives.strength_mult;
366 stats.positive_agility_bonus = (state.primary.agility - state.create_primary.agility).max(0.0)
367 * stat_passives.agility_mult;
368 stats.positive_stamina_bonus = (state.primary.stamina - state.create_primary.stamina).max(0.0)
369 * stat_passives.stamina_mult;
370 stats.positive_intellect_bonus = (state.primary.intellect - state.create_primary.intellect)
371 .max(0.0)
372 * stat_passives.intellect_mult;
373}
374
375pub(super) fn apply_matching_armor_specialization(
376 primary: &mut PrimaryStats,
377 spec: SpecId,
378 stat_passives: &PassiveStatMods,
379 matching_armor: bool,
380) {
381 if !matching_armor {
382 return;
383 }
384
385 let attribute = primary_stat_for_spec(spec);
386 let percent = stat_passives.matching_armor.percent_for(attribute);
387
388 primary.set(
389 attribute,
390 primary.get(attribute) * (1.0 + percent / HUNDRED),
391 );
392}
393
394fn equipped_item(entry: &GearEntry, resolved: &ResolvedItem) -> EquippedItem {
395 EquippedItem {
396 slot: entry.slot,
397 item_id: entry.id,
398 item_level: resolved.item_level,
399 quality: resolved.quality,
400 enchant_id: entry.enchant_id,
401 effects: resolved.effects.clone(),
402 }
403}
404
405fn collect_set_bonus_auras(
406 spec: SpecId,
407 set_counts: &FastMap<i32, u32>,
408 set_infos: &FastMap<i32, ItemSetInfo>,
409) -> Vec<u32> {
410 let wow_spec_id = wowlab_types::numeric::u32_to_i32_saturating(spec.wow_spec_id());
411 let mut set_bonus_auras: Vec<u32> = Vec::new();
412
413 for (&set_id, &count) in set_counts {
414 if let Some(info) = set_infos.get(&set_id) {
415 for bonus in &info.bonuses {
416 if count >= wowlab_types::numeric::i32_to_u32_nonnegative(bonus.threshold)
417 && (bonus.spec_id == 0 || bonus.spec_id == wow_spec_id)
418 {
419 set_bonus_auras.push(wowlab_types::numeric::i32_to_u32_nonnegative(
420 bonus.spell_id,
421 ));
422 }
423 }
424 }
425 }
426
427 set_bonus_auras
428}