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