1use wowlab_engine_gamedata::RatingMultiplierSlot;
4use wowlab_types::{
5 constants::HUNDRED,
6 data::{ResolvedItem, SpellDataFlat},
7 game::{Attribute, ClassId, GearSlot, RatingType, SpecId},
8};
9
10use crate::{
11 dbc::{ArmorSubclass, AttributeMask, AuraSubtypeKind, InventoryType, ItemClass},
12 stats::{PrimaryStats, Ratings, primary_stat_for_spec},
13};
14
15const BASE_CRIT_DAMAGE_MULTIPLIER: f64 = 2.0;
16const CRAFTED_PLACEHOLDER_STAT_IDS: [i32; 2] = [24, 25];
17const CRIT_EFFECTIVENESS_PER_UNIQUE_COLOR: f64 = 0.0015;
18
19#[must_use]
21pub fn rating_multiplier_slot(inventory_type: i32) -> Option<RatingMultiplierSlot> {
22 match InventoryType::try_from(inventory_type).ok() {
23 Some(InventoryType::Neck | InventoryType::Finger) => Some(RatingMultiplierSlot::Jewelry),
24 Some(InventoryType::Trinket) => Some(RatingMultiplierSlot::Trinket),
25 Some(
26 InventoryType::Weapon
27 | InventoryType::TwoHandWeapon
28 | InventoryType::MainHandWeapon
29 | InventoryType::OffHandWeapon
30 | InventoryType::Ranged
31 | InventoryType::RangedRight
32 | InventoryType::Thrown,
33 ) => Some(RatingMultiplierSlot::Weapon),
34 Some(
35 InventoryType::Head
36 | InventoryType::Shoulder
37 | InventoryType::Body
38 | InventoryType::Chest
39 | InventoryType::Waist
40 | InventoryType::Legs
41 | InventoryType::Feet
42 | InventoryType::Wrists
43 | InventoryType::Hands
44 | InventoryType::Shield
45 | InventoryType::Cloak
46 | InventoryType::Robe
47 | InventoryType::Holdable,
48 ) => Some(RatingMultiplierSlot::Armor),
49 _ => None,
50 }
51}
52
53const fn inventory_type_bit(kind: InventoryType) -> i32 {
54 1_i32 << kind as i32
55}
56
57const MATCHING_ARMOR_INVENTORY_MASK: i32 = inventory_type_bit(InventoryType::Head)
58 | inventory_type_bit(InventoryType::Shoulder)
59 | inventory_type_bit(InventoryType::Chest)
60 | inventory_type_bit(InventoryType::Waist)
61 | inventory_type_bit(InventoryType::Legs)
62 | inventory_type_bit(InventoryType::Feet)
63 | inventory_type_bit(InventoryType::Wrists)
64 | inventory_type_bit(InventoryType::Hands)
65 | inventory_type_bit(InventoryType::Robe);
66
67#[rustfmt::skip]
69pub const MATCHING_ARMOR_SLOTS: [GearSlot; 8] = [
70 GearSlot::Head,
71 GearSlot::Shoulders,
72 GearSlot::Chest,
73 GearSlot::Waist,
74 GearSlot::Legs,
75 GearSlot::Feet,
76 GearSlot::Wrists,
77 GearSlot::Hands,
78];
79
80#[derive(Clone, Copy, Debug, Default, PartialEq)]
82pub struct MatchingArmorPassive {
83 strength: Percent,
84 agility: Percent,
85 stamina: Percent,
86 intellect: Percent,
87}
88
89impl MatchingArmorPassive {
90 #[must_use]
92 pub const fn new() -> Self {
93 Self {
94 strength: Percent::ZERO,
95 agility: Percent::ZERO,
96 stamina: Percent::ZERO,
97 intellect: Percent::ZERO,
98 }
99 }
100
101 #[must_use]
103 pub const fn percent_for(self, attribute: Attribute) -> f64 {
104 match attribute {
105 Attribute::Strength => self.strength.value(),
106 Attribute::Agility => self.agility.value(),
107 Attribute::Stamina => self.stamina.value(),
108 Attribute::Intellect => self.intellect.value(),
109 }
110 }
111
112 pub(crate) fn add_scaled(&mut self, other: Self, scale: f64) {
113 self.strength.add_scaled(other.strength, scale);
114 self.agility.add_scaled(other.agility, scale);
115 self.stamina.add_scaled(other.stamina, scale);
116 self.intellect.add_scaled(other.intellect, scale);
117 }
118
119 fn add_effect(&mut self, stat_mask: i32, percent: f64) {
120 let mask = AttributeMask::from_dbc(stat_mask);
121
122 if mask.contains(AttributeMask::STRENGTH) {
123 self.strength.add(percent);
124 }
125
126 if mask.contains(AttributeMask::AGILITY) {
127 self.agility.add(percent);
128 }
129
130 if mask.contains(AttributeMask::STAMINA) {
131 self.stamina.add(percent);
132 }
133
134 if mask.contains(AttributeMask::INTELLECT) {
135 self.intellect.add(percent);
136 }
137 }
138}
139
140#[derive(Clone, Copy, Debug, Default, PartialEq)]
141struct Percent(f64);
142
143impl Percent {
144 const ZERO: Self = Self(0.0);
145
146 const fn value(self) -> f64 {
147 self.0
148 }
149
150 fn add(&mut self, value: f64) {
151 self.0 += value;
152 }
153
154 fn add_scaled(&mut self, other: Self, scale: f64) {
155 self.0 += other.0 * scale;
156 }
157}
158
159#[must_use]
164pub fn matching_armor_specialization_passive(
165 spell: &SpellDataFlat,
166) -> Option<MatchingArmorPassive> {
167 if !spell.is_passive {
168 return None;
169 }
170
171 let requirement = spell.equipped_item_requirement?;
172
173 if requirement.item_class != ItemClass::Armor as i32
174 || requirement.inventory_type_mask & MATCHING_ARMOR_INVENTORY_MASK
175 != MATCHING_ARMOR_INVENTORY_MASK
176 || requirement.subclass_mask == 0
177 {
178 return None;
179 }
180
181 let mut passive = MatchingArmorPassive::default();
182 let mut has_stat_effect = false;
183
184 for effect in &spell.effects {
185 if effect.aura != AuraSubtypeKind::ModTotalStatPercentage as i32 {
186 continue;
187 }
188
189 passive.add_effect(effect.misc_value_1, effect.base_points);
190 has_stat_effect = true;
191 }
192
193 has_stat_effect.then_some(passive)
194}
195
196#[must_use]
198pub const fn class_armor_subclass(class: ClassId) -> ArmorSubclass {
199 match class {
200 ClassId::Warrior | ClassId::Paladin | ClassId::DeathKnight => ArmorSubclass::Plate,
201 ClassId::Hunter | ClassId::Shaman | ClassId::Evoker => ArmorSubclass::Mail,
202 ClassId::Rogue | ClassId::Monk | ClassId::Druid | ClassId::DemonHunter => {
203 ArmorSubclass::Leather
204 }
205 ClassId::Mage | ClassId::Priest | ClassId::Warlock => ArmorSubclass::Cloth,
206 }
207}
208
209#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
211#[non_exhaustive]
212pub enum StatType {
213 Agility,
214 Strength,
215 Intellect,
216 Stamina,
217 Crit,
218 Haste,
219 Versatility,
220 Mastery,
221 Avoidance,
222 Leech,
223 Speed,
224 ComboPrimary,
225}
226
227#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229#[non_exhaustive]
230pub enum StatBucket {
231 Primary(Attribute),
232 Rating(RatingType),
233}
234
235macro_rules! stat_type_table {
237 ( $( $variant:ident, $canonical_id:literal $(, $alias_id:literal )* => $bucket:expr ; )+ ) => {
238 impl TryFrom<i32> for StatType {
239 type Error = UnknownStatType;
240
241 fn try_from(value: i32) -> Result<Self, Self::Error> {
242 match value {
243 $( $canonical_id $( | $alias_id )* => Ok(StatType::$variant), )+
244 COMBO_PRIMARY_STAT_MIN..=COMBO_PRIMARY_STAT_MAX => Ok(StatType::ComboPrimary),
245 other => Err(UnknownStatType(other)),
246 }
247 }
248 }
249
250 impl From<StatType> for i32 {
251 fn from(value: StatType) -> Self {
252 match value {
253 $( StatType::$variant => $canonical_id, )+
254 StatType::ComboPrimary => COMBO_PRIMARY_STAT_MIN,
255 }
256 }
257 }
258
259 impl StatType {
260 pub fn resolve_for_spec(self, spec: SpecId) -> StatBucket {
262 match self {
263 $( StatType::$variant => $bucket, )+
264 StatType::ComboPrimary => StatBucket::Primary(primary_stat_for_spec(spec)),
265 }
266 }
267 }
268 };
269}
270
271const COMBO_PRIMARY_STAT_MIN: i32 = 71;
272const COMBO_PRIMARY_STAT_MAX: i32 = 74;
273
274stat_type_table! {
275 Agility, 3 => StatBucket::Primary(Attribute::Agility);
276 Strength, 4 => StatBucket::Primary(Attribute::Strength);
277 Intellect, 5 => StatBucket::Primary(Attribute::Intellect);
278 Stamina, 7 => StatBucket::Primary(Attribute::Stamina);
279 Crit, 32 => StatBucket::Rating(RatingType::Crit);
280 Haste, 36 => StatBucket::Rating(RatingType::Haste);
281 Versatility, 40, 61 => StatBucket::Rating(RatingType::Versatility);
282 Mastery, 49 => StatBucket::Rating(RatingType::Mastery);
283 Avoidance, 91 => StatBucket::Rating(RatingType::Avoidance);
284 Leech, 93 => StatBucket::Rating(RatingType::Leech);
285 Speed, 94 => StatBucket::Rating(RatingType::Speed);
286}
287
288#[derive(Clone, Copy, Debug, Eq, PartialEq)]
290pub struct UnknownStatType(i32);
291
292impl UnknownStatType {
293 #[must_use]
295 pub const fn value(self) -> i32 {
296 self.0
297 }
298}
299
300impl core::fmt::Display for UnknownStatType {
301 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
302 write!(f, "unknown WoW stat_type {}", self.0)
303 }
304}
305
306impl core::error::Error for UnknownStatType {}
307
308pub fn apply_item_stat(
310 primary: &mut PrimaryStats,
311 ratings: &mut Ratings,
312 spec: SpecId,
313 stat_type: i32,
314 value: f64,
315) {
316 let typed = match StatType::try_from(stat_type) {
317 Ok(typed) => typed,
318 Err(error) => {
319 tracing::warn!(
320 target: "wowlab::gear",
321 stat_type = error.value(),
322 value,
323 "unknown WoW stat_type id; item stat dropped"
324 );
325
326 return;
327 }
328 };
329
330 match typed.resolve_for_spec(spec) {
331 StatBucket::Primary(attr) => primary.add(attr, value),
332 StatBucket::Rating(rating) => ratings.add(rating, value),
333 }
334}
335
336#[must_use]
338pub fn scale_rating_item_stat(stat_type: i32, value: f64, rating_multiplier: f64) -> f64 {
339 match StatType::try_from(stat_type) {
340 Ok(
341 StatType::Crit
342 | StatType::Haste
343 | StatType::Versatility
344 | StatType::Mastery
345 | StatType::Avoidance
346 | StatType::Leech
347 | StatType::Speed,
348 ) => (value * rating_multiplier).round(),
349 _ => value,
350 }
351}
352
353pub fn apply_resolved_item_stats(
355 item: &ResolvedItem,
356 crafted_stats: Option<&[u32]>,
357 rating_multiplier: f64,
358 primary: &mut PrimaryStats,
359 ratings: &mut Ratings,
360 spec: SpecId,
361) {
362 for stat in &item.stats {
363 let stat_type = remap_crafted_stat(stat.stat_type, crafted_stats);
364 let value = scale_rating_item_stat(stat_type, f64::from(stat.value), rating_multiplier);
365
366 apply_item_stat(primary, ratings, spec, stat_type, value);
367 }
368}
369
370fn remap_crafted_stat(stat_type: i32, crafted_stats: Option<&[u32]>) -> i32 {
371 let Some(crafted_stats) = crafted_stats else {
372 return stat_type;
373 };
374
375 let crafted_stat = CRAFTED_PLACEHOLDER_STAT_IDS
376 .iter()
377 .position(|&placeholder| placeholder == stat_type)
378 .and_then(|slot| crafted_stats.get(slot));
379
380 crafted_stat.map_or(stat_type, |&stat| {
381 wowlab_types::numeric::u32_to_i32_saturating(stat)
382 })
383}
384
385#[must_use]
387pub fn crit_effectiveness_with_unique_gems(
388 current_multiplier: f64,
389 unique_color_count: usize,
390) -> f64 {
391 let count = f64::from(wowlab_types::numeric::usize_to_u32_saturating(
392 unique_color_count,
393 ));
394
395 current_multiplier * (1.0 + CRIT_EFFECTIVENESS_PER_UNIQUE_COLOR * count)
396}
397
398#[must_use]
400pub const fn crit_effectiveness_with_percent(current_multiplier: f64, percent: f64) -> f64 {
401 current_multiplier * (1.0 + percent / HUNDRED)
402}
403
404#[must_use]
406pub const fn crit_damage_bonus_from_effectiveness(multiplier: f64) -> f64 {
407 (BASE_CRIT_DAMAGE_MULTIPLIER * multiplier - BASE_CRIT_DAMAGE_MULTIPLIER) * HUNDRED
408}
409
410#[must_use]
412pub fn gem_color_key(name: &str) -> Option<&str> {
413 (!name.contains("Diamond"))
414 .then(|| name.split_whitespace().next_back())
415 .flatten()
416}
417
418#[cfg(test)]
419mod tests {
420 use googletest::prelude::*;
421
422 use super::*;
423
424 #[gtest]
425 fn primary_stat_ids_decode() {
426 expect_that!(StatType::try_from(3), eq(Ok(StatType::Agility)));
427 expect_that!(StatType::try_from(4), eq(Ok(StatType::Strength)));
428 expect_that!(StatType::try_from(5), eq(Ok(StatType::Intellect)));
429 expect_that!(StatType::try_from(7), eq(Ok(StatType::Stamina)));
430 }
431
432 #[gtest]
433 fn rating_ids_decode() {
434 expect_that!(StatType::try_from(32), eq(Ok(StatType::Crit)));
435 expect_that!(StatType::try_from(36), eq(Ok(StatType::Haste)));
436 expect_that!(StatType::try_from(49), eq(Ok(StatType::Mastery)));
437 expect_that!(StatType::try_from(91), eq(Ok(StatType::Avoidance)));
438 expect_that!(StatType::try_from(93), eq(Ok(StatType::Leech)));
439 expect_that!(StatType::try_from(94), eq(Ok(StatType::Speed)));
440 }
441
442 #[gtest]
443 fn versatility_accepts_both_ids() {
444 expect_that!(StatType::try_from(40), eq(Ok(StatType::Versatility)));
445 expect_that!(StatType::try_from(61), eq(Ok(StatType::Versatility)));
446 }
447
448 #[gtest]
449 fn combo_primary_range_is_inclusive() {
450 for id in 71..=74 {
451 expect_that!(StatType::try_from(id), eq(Ok(StatType::ComboPrimary)));
452 }
453 }
454
455 #[gtest]
456 fn legacy_ids_are_unknown() {
457 for id in [31, 37, 13, 14] {
458 expect_that!(StatType::try_from(id).is_err(), is_true());
459 }
460 }
461
462 #[gtest]
463 fn round_trip_canonical_ids() {
464 for typed in [
465 StatType::Agility,
466 StatType::Strength,
467 StatType::Intellect,
468 StatType::Stamina,
469 StatType::Crit,
470 StatType::Haste,
471 StatType::Mastery,
472 StatType::Avoidance,
473 StatType::Leech,
474 StatType::Speed,
475 ] {
476 let raw: i32 = typed.into();
477
478 expect_that!(StatType::try_from(raw), eq(Ok(typed)));
479 }
480 }
481
482 #[gtest]
483 fn versatility_canonical_id_is_damage_done() {
484 let raw: i32 = StatType::Versatility.into();
485
486 expect_that!(raw, eq(40));
487 expect_that!(StatType::try_from(raw), eq(Ok(StatType::Versatility)));
488 }
489
490 #[gtest]
491 fn combo_primary_resolves_to_spec_primary() {
492 expect_that!(
493 StatType::ComboPrimary.resolve_for_spec(SpecId::Outlaw),
494 eq(StatBucket::Primary(Attribute::Agility))
495 );
496 expect_that!(
497 StatType::ComboPrimary.resolve_for_spec(SpecId::Arms),
498 eq(StatBucket::Primary(Attribute::Strength))
499 );
500 expect_that!(
501 StatType::ComboPrimary.resolve_for_spec(SpecId::FrostMage),
502 eq(StatBucket::Primary(Attribute::Intellect))
503 );
504 }
505
506 #[gtest]
507 fn apply_item_stat_routes_primary() {
508 let mut primary = PrimaryStats::default();
509 let mut ratings = Ratings::default();
510
511 apply_item_stat(&mut primary, &mut ratings, SpecId::Outlaw, 3, 100.0);
512 expect_that!(primary.agility, near(100.0, f64::EPSILON));
513 }
514
515 #[gtest]
516 fn apply_item_stat_routes_rating() {
517 let mut primary = PrimaryStats::default();
518 let mut ratings = Ratings::default();
519
520 apply_item_stat(&mut primary, &mut ratings, SpecId::Outlaw, 32, 250.0);
521 expect_that!(ratings.crit, near(250.0, f64::EPSILON));
522 }
523
524 #[gtest]
525 fn apply_item_stat_combo_primary_uses_spec_primary() {
526 let mut primary = PrimaryStats::default();
527 let mut ratings = Ratings::default();
528
529 apply_item_stat(&mut primary, &mut ratings, SpecId::Outlaw, 72, 50.0);
530 expect_that!(primary.agility, near(50.0, f64::EPSILON));
531
532 let mut primary = PrimaryStats::default();
533 let mut ratings = Ratings::default();
534
535 apply_item_stat(&mut primary, &mut ratings, SpecId::Arms, 71, 50.0);
536 expect_that!(primary.strength, near(50.0, f64::EPSILON));
537 }
538
539 #[gtest]
540 fn rating_multiplier_slot_mirrors_simc() {
541 expect_that!(
542 rating_multiplier_slot(InventoryType::Neck as i32),
543 eq(Some(RatingMultiplierSlot::Jewelry))
544 );
545 expect_that!(
546 rating_multiplier_slot(InventoryType::Finger as i32),
547 eq(Some(RatingMultiplierSlot::Jewelry))
548 );
549 expect_that!(
550 rating_multiplier_slot(InventoryType::Trinket as i32),
551 eq(Some(RatingMultiplierSlot::Trinket))
552 );
553 expect_that!(
554 rating_multiplier_slot(InventoryType::TwoHandWeapon as i32),
555 eq(Some(RatingMultiplierSlot::Weapon))
556 );
557 expect_that!(
558 rating_multiplier_slot(InventoryType::Chest as i32),
559 eq(Some(RatingMultiplierSlot::Armor))
560 );
561 expect_that!(rating_multiplier_slot(0), eq(None));
562 expect_that!(rating_multiplier_slot(19), eq(None));
563 }
564
565 #[gtest]
566 fn scale_rating_item_stat_scales_ratings_only() {
567 let jewelry_mult = 1.305_538_02;
568
569 expect_that!(
570 scale_rating_item_stat(32, 100.0, jewelry_mult),
571 near(131.0, f64::EPSILON)
572 );
573 expect_that!(
574 scale_rating_item_stat(4, 100.0, jewelry_mult),
575 near(100.0, f64::EPSILON)
576 );
577 expect_that!(
578 scale_rating_item_stat(7, 100.0, jewelry_mult),
579 near(100.0, f64::EPSILON)
580 );
581 }
582
583 #[gtest]
584 fn apply_item_stat_unknown_id_is_noop() {
585 let mut primary = PrimaryStats::default();
586 let mut ratings = Ratings::default();
587
588 apply_item_stat(&mut primary, &mut ratings, SpecId::Outlaw, 999, 100.0);
589 expect_that!(primary.agility.abs() < f64::EPSILON, is_true());
590 expect_that!(ratings.crit.abs() < f64::EPSILON, is_true());
591 }
592
593 #[gtest]
594 fn midnight_gem_color_uses_the_color_name_and_excludes_meta_gems() -> Result<()> {
595 verify_that!(gem_color_key("Flawless Masterful Lapis"), some(eq("Lapis")))?;
596
597 verify_that!(gem_color_key("Powerful Eversong Diamond"), none())
598 }
599}