Skip to main content

wowlab_engine_domain/
gear.rs

1//! Gear stat decoding: typed `StatType` enum over `WoW` DBC `stat_type` IDs plus [`apply_item_stat`].
2
3use 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/// Returns the rating-multiplier column for an inventory type.
20#[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/// Armor slots required for specialization; cloaks are exempt.
68#[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/// DBC-provided primary-stat percentages gated by matching equipped armor.
81#[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    /// Returns an empty matching-armor payload.
91    #[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    /// Returns the DBC percentage for `attribute`.
102    #[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/// Resolves a matching-armor specialization passive and its DBC stat payload.
160///
161/// Gear resolution owns whether the equipped armor satisfies the requirement.
162/// This function only identifies the requirement and retains its aura-137 stat percentages.
163#[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/// Returns a class's specialized armor subclass; mirrors `SimC`'s `util::matching_armor_type`.
197#[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/// Maps a `WoW` DBC `stat_type` integer onto the engine's typed stat buckets.
210#[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/// Spec-resolved destination bucket for an item-stat row.
228#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229#[non_exhaustive]
230pub enum StatBucket {
231    Primary(Attribute),
232    Rating(RatingType),
233}
234
235// Versatility has two ids: damage-done (40, canonical) and combined (61); both decode to the same bucket.
236macro_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            /// Resolve to a spec-resolved [`StatBucket`].
261            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/// Returned by [`StatType::try_from`] for an unrecognised `stat_type` integer.
289#[derive(Clone, Copy, Debug, Eq, PartialEq)]
290pub struct UnknownStatType(i32);
291
292impl UnknownStatType {
293    /// Returns the unrecognized DBC stat identifier.
294    #[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
308/// Apply a single item-stat row onto `primary` / `ratings`; unknown ids are dropped and logged.
309pub 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/// Scales and rounds rating stats by the slot multiplier.
337#[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
353/// Apply all resolved item-stat rows, including crafted placeholder remapping.
354pub 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/// Fold the Powerful Eversong Diamond bonus over distinct Midnight gem colors.
386#[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/// Apply a percentage increase to critical-strike effectiveness.
399#[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/// Convert critical-strike effectiveness into bonus critical-damage percentage points.
405#[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/// Extract the Midnight gem color suffix while excluding meta gems.
411#[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}