Skip to main content

wowlab_types/types/game/
gear_slot.rs

1use num_enum::TryFromPrimitive;
2use serde::{Deserialize, Serialize};
3use strum::{Display, EnumString};
4#[cfg(feature = "wasm")]
5use tsify::Tsify;
6
7/// Equipment slot; `repr(u8)` matches `WoW` `inventory_type` IDs.
8#[derive(
9    Clone,
10    Copy,
11    Debug,
12    Deserialize,
13    Display,
14    EnumString,
15    Eq,
16    Hash,
17    PartialEq,
18    Serialize,
19    TryFromPrimitive,
20)]
21#[cfg_attr(feature = "wasm", derive(Tsify))]
22#[serde(rename_all = "snake_case")]
23#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
24#[repr(u8)]
25// #t(rust_non_exhaustive_on_public) WoW equipment slots are game-defined
26pub enum GearSlot {
27    Head = 1,
28    Neck = 2,
29    #[serde(alias = "shoulder")]
30    #[strum(serialize = "shoulders", serialize = "shoulder")]
31    Shoulders = 3,
32    Shirt = 4,
33    Chest = 5,
34    Waist = 6,
35    Legs = 7,
36    Feet = 8,
37    #[serde(alias = "wrist")]
38    #[strum(serialize = "wrists", serialize = "wrist")]
39    Wrists = 9,
40    Hands = 10,
41    Finger1 = 11,
42    Finger2 = 12,
43    Trinket1 = 13,
44    Trinket2 = 14,
45    #[serde(alias = "cloak")]
46    #[strum(serialize = "back", serialize = "cloak")]
47    Back = 16,
48    Tabard = 19,
49    MainHand = 21,
50    OffHand = 22,
51}
52
53/// A single equipped item shared by simulation intents and parsed profiles.
54#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
55#[cfg_attr(feature = "wasm", derive(Tsify))]
56pub struct GearEntry {
57    pub slot: GearSlot,
58    pub id: u32,
59    #[serde(alias = "bonusIds", skip_serializing_if = "Option::is_none")]
60    #[cfg_attr(feature = "wasm", tsify(optional))]
61    pub bonus_ids: Option<Vec<u32>>,
62    #[serde(alias = "enchantId", skip_serializing_if = "Option::is_none")]
63    #[cfg_attr(feature = "wasm", tsify(optional))]
64    pub enchant_id: Option<u32>,
65    #[serde(alias = "gemIds", skip_serializing_if = "Option::is_none")]
66    #[cfg_attr(feature = "wasm", tsify(optional))]
67    pub gem_ids: Option<Vec<u32>>,
68    #[serde(alias = "craftedStats", skip_serializing_if = "Option::is_none")]
69    #[cfg_attr(feature = "wasm", tsify(optional))]
70    pub crafted_stats: Option<Vec<u32>>,
71    #[serde(alias = "craftingQuality", skip_serializing_if = "Option::is_none")]
72    #[cfg_attr(feature = "wasm", tsify(optional))]
73    pub crafting_quality: Option<u8>,
74    #[serde(alias = "dropLevel", skip_serializing_if = "Option::is_none")]
75    #[cfg_attr(feature = "wasm", tsify(optional))]
76    pub drop_level: Option<u32>,
77    /// Forced final item level, overriding bonus-derived scaling.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    #[cfg_attr(feature = "wasm", tsify(optional))]
80    pub ilevel: Option<u32>,
81}
82
83impl GearSlot {
84    // #t(block: rust_cyclomatic_complexity) 1:1 variant-to-slug match table
85    #[must_use]
86    pub const fn slug(&self) -> &'static str {
87        match self {
88            // tidy-alphabetical-start
89            Self::Back => "back",
90            Self::Chest => "chest",
91            Self::Feet => "feet",
92            Self::Finger1 => "finger1",
93            Self::Finger2 => "finger2",
94            Self::Hands => "hands",
95            Self::Head => "head",
96            Self::Legs => "legs",
97            Self::MainHand => "main_hand",
98            Self::Neck => "neck",
99            Self::OffHand => "off_hand",
100            Self::Shirt => "shirt",
101            Self::Shoulders => "shoulders",
102            Self::Tabard => "tabard",
103            Self::Trinket1 => "trinket1",
104            Self::Trinket2 => "trinket2",
105            Self::Waist => "waist",
106            Self::Wrists => "wrists",
107            // tidy-alphabetical-end
108        }
109    }
110
111    /// Rotation on-use action key for this slot (e.g. `trinket_1_use`), if it has one.
112    #[must_use]
113    pub const fn use_alias(&self) -> Option<&'static str> {
114        match self {
115            Self::Trinket1 => Some("trinket_1_use"),
116            Self::Trinket2 => Some("trinket_2_use"),
117            _ => None,
118        }
119    }
120
121    /// Parse from a slug; accepts singular aliases (`shoulder`/`wrist`, `cloak`, `trinket_1`).
122    // #t(block: rust_cyclomatic_complexity) 1:1 slug-to-variant match table
123    #[must_use]
124    pub fn from_slug(s: &str) -> Option<GearSlot> {
125        Some(match s {
126            // tidy-alphabetical-start
127            "back" | "cloak" => Self::Back,
128            "chest" => Self::Chest,
129            "feet" => Self::Feet,
130            "finger1" => Self::Finger1,
131            "finger2" => Self::Finger2,
132            "hands" => Self::Hands,
133            "head" => Self::Head,
134            "legs" => Self::Legs,
135            "main_hand" => Self::MainHand,
136            "neck" => Self::Neck,
137            "off_hand" => Self::OffHand,
138            "shirt" => Self::Shirt,
139            "shoulders" | "shoulder" => Self::Shoulders,
140            "tabard" => Self::Tabard,
141            "trinket1" | "trinket_1" => Self::Trinket1,
142            "trinket2" | "trinket_2" => Self::Trinket2,
143            "waist" => Self::Waist,
144            "wrists" | "wrist" => Self::Wrists,
145            // tidy-alphabetical-end
146            _ => return None,
147        })
148    }
149
150    #[must_use]
151    pub fn trinket_from_index(slot: u8) -> Option<GearSlot> {
152        match slot {
153            1 => Some(Self::Trinket1),
154            2 => Some(Self::Trinket2),
155            _ => None,
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use googletest::prelude::*;
163
164    use super::*;
165
166    #[gtest]
167    fn slug_roundtrip() -> Result<()> {
168        let slots = [
169            GearSlot::Head,
170            GearSlot::Neck,
171            GearSlot::Shoulders,
172            GearSlot::Shirt,
173            GearSlot::Chest,
174            GearSlot::Waist,
175            GearSlot::Legs,
176            GearSlot::Feet,
177            GearSlot::Wrists,
178            GearSlot::Hands,
179            GearSlot::Finger1,
180            GearSlot::Finger2,
181            GearSlot::Trinket1,
182            GearSlot::Trinket2,
183            GearSlot::Back,
184            GearSlot::Tabard,
185            GearSlot::MainHand,
186            GearSlot::OffHand,
187        ];
188
189        for slot in slots {
190            let slug = slot.slug();
191            let parsed = GearSlot::from_slug(slug);
192
193            scoped_trace!("round-trip for {slot:?} -> {slug}");
194            verify_that!(parsed, some(eq(slot)))?;
195        }
196
197        Ok(())
198    }
199
200    #[gtest]
201    fn from_slug_aliases() -> Result<()> {
202        wowlab_test_support::verify_all!(
203            GearSlot::from_slug("shoulder") => some(eq(GearSlot::Shoulders)),
204            GearSlot::from_slug("wrist") => some(eq(GearSlot::Wrists)),
205            GearSlot::from_slug("cloak") => some(eq(GearSlot::Back)),
206            GearSlot::from_slug("trinket_1") => some(eq(GearSlot::Trinket1)),
207            GearSlot::from_slug("trinket_2") => some(eq(GearSlot::Trinket2)),
208        )
209    }
210
211    #[gtest]
212    fn from_slug_unknown() -> Result<()> {
213        verify_that!(GearSlot::from_slug("unknown"), none())?;
214
215        verify_that!(GearSlot::from_slug(""), none())
216    }
217
218    #[gtest]
219    fn trinket_from_index_valid() -> Result<()> {
220        verify_that!(
221            GearSlot::trinket_from_index(1),
222            some(eq(GearSlot::Trinket1))
223        )?;
224
225        verify_that!(
226            GearSlot::trinket_from_index(2),
227            some(eq(GearSlot::Trinket2))
228        )
229    }
230
231    #[gtest]
232    fn trinket_from_index_invalid() -> Result<()> {
233        verify_that!(GearSlot::trinket_from_index(0), none())?;
234
235        verify_that!(GearSlot::trinket_from_index(3), none())
236    }
237
238    #[gtest]
239    fn try_from_primitive() -> Result<()> {
240        wowlab_test_support::verify_all!(
241            GearSlot::try_from(1u8) => ok(eq(GearSlot::Head)),
242            GearSlot::try_from(13u8) => ok(eq(GearSlot::Trinket1)),
243            GearSlot::try_from(21u8) => ok(eq(GearSlot::MainHand)),
244            GearSlot::try_from(0u8) => err(anything()),
245        )
246    }
247
248    #[gtest]
249    fn serde_roundtrip() -> Result<()> {
250        let slot = GearSlot::Shoulders;
251        let json = serde_json::to_string(&slot).or_fail()?;
252
253        verify_that!(json, eq("\"shoulders\""))?;
254        let parsed: GearSlot = serde_json::from_str(&json).or_fail()?;
255
256        verify_that!(parsed, eq(slot))
257    }
258
259    #[gtest]
260    fn serde_accepts_simc_plurals_and_singular_alias() -> Result<()> {
261        let cases = [
262            ("\"shoulders\"", GearSlot::Shoulders),
263            ("\"shoulder\"", GearSlot::Shoulders),
264            ("\"wrists\"", GearSlot::Wrists),
265            ("\"wrist\"", GearSlot::Wrists),
266            ("\"back\"", GearSlot::Back),
267            ("\"cloak\"", GearSlot::Back),
268            ("\"main_hand\"", GearSlot::MainHand),
269            ("\"off_hand\"", GearSlot::OffHand),
270        ];
271
272        for (json, expected) in cases {
273            let parsed: GearSlot = serde_json::from_str(json).or_fail()?;
274
275            scoped_trace!("deserialize {json}");
276            verify_that!(parsed, eq(expected))?;
277        }
278
279        Ok(())
280    }
281
282    #[gtest]
283    fn parse_case_insensitive() -> Result<()> {
284        use std::str::FromStr;
285        verify_that!(
286            GearSlot::from_str("Shoulders").or_fail()?,
287            eq(GearSlot::Shoulders)
288        )?;
289
290        verify_that!(
291            GearSlot::from_str("MAIN_HAND").or_fail()?,
292            eq(GearSlot::MainHand)
293        )
294    }
295}