Skip to main content

steam_user/utils/
container.rs

1//! Detection of "container" inventory items (weapon cases, capsules,
2//! sticker packs, gifts, etc.).
3
4/// Market-hash names of well-known containers, used as a last-resort fallback
5/// when an item's tags or `type` field don't carry the standard
6/// `"Container"` / `"Base Grade Container"` markers.
7///
8/// In practice, items returned by Steam's inventory APIs always carry tag and
9/// type metadata, so this list is rarely the deciding signal — but trade
10/// history and partial trade-offer payloads occasionally strip those fields,
11/// and the name fallback keeps detection robust in those cases.
12///
13/// Kept in sync with the case-image registry in
14/// `steam-support-egui/src/ui/views/account_details/inventory.
15/// rs::local_case_image`.
16const KNOWN_CONTAINER_NAMES: &[&str] = &[
17    // CS:GO / CS2 weapon cases (chronological)
18    "CS:GO Weapon Case",
19    "eSports 2013 Case",
20    "Operation Bravo Case",
21    "CS:GO Weapon Case 2",
22    "eSports 2013 Winter Case",
23    "Winter Offensive Weapon Case",
24    "CS:GO Weapon Case 3",
25    "Operation Phoenix Weapon Case",
26    "Huntsman Weapon Case",
27    "Operation Breakout Weapon Case",
28    "eSports 2014 Summer Case",
29    "Operation Vanguard Weapon Case",
30    "Chroma Case",
31    "Chroma 2 Case",
32    "Falchion Case",
33    "Shadow Case",
34    "Revolver Case",
35    "Operation Wildfire Case",
36    "Chroma 3 Case",
37    "Gamma Case",
38    "Gamma 2 Case",
39    "Glove Case",
40    "Spectrum Case",
41    "Operation Hydra Case",
42    "Spectrum 2 Case",
43    "Clutch Case",
44    "CS20 Case",
45    "Danger Zone Case",
46    "Horizon Case",
47    "Prisma Case",
48    "Shattered Web Case",
49    "Prisma 2 Case",
50    "Fracture Case",
51    "Operation Broken Fang Case",
52    "Snakebite Case",
53    "Operation Riptide Case",
54    "Dreams & Nightmares Case",
55    "Recoil Case",
56    "Revolution Case",
57    "Kilowatt Case",
58    "Gallery Case",
59    // Souvenir / sealed packages
60    "Sealed Genesis Terminal",
61    // Common sticker capsules
62    "Sticker Capsule",
63    "Sticker Capsule 2",
64    "Community Sticker Capsule 1",
65];
66
67/// Returns `true` when an inventory item is a *container* — a weapon case,
68/// capsule, sticker pack, gift, or other "openable" item.
69///
70/// Three signals are checked, in order of strength:
71///
72/// 1. **Item type label** — the Steam-rendered `type` string equals `"Base
73///    Grade Container"`. This is the canonical type Steam attaches to standard
74///    cases.
75/// 2. **Tag metadata** — any tag has both `category == "Type"` and
76///    `localized_tag_name == "Container"`. The strongest structural signal, but
77///    uses the *localized* tag name, so callers must fetch inventories with
78///    `l=english`.
79/// 3. **Name fallback** — the item's `market_hash_name` (or `name`) appears in
80///    a curated [`KNOWN_CONTAINER_NAMES`] list. Used when older or trimmed
81///    payloads (e.g. trade-history HTML) drop tag/type metadata.
82///
83/// `tags` accepts any iterator yielding `(category, localized_tag_name)`
84/// string pairs, so callers can adapt typed structs (`InventoryApiTag`,
85/// `InventoryItemTag`, `InventoryHistoryTag`) and BSON tag documents alike
86/// without copying.
87pub fn is_inventory_container_item<'a, I>(item_type: &str, item_name: &str, tags: I) -> bool
88where
89    I: IntoIterator<Item = (&'a str, &'a str)>,
90{
91    if item_type == "Base Grade Container" {
92        return true;
93    }
94    if tags.into_iter().any(|(category, localized_tag_name)| category == "Type" && localized_tag_name == "Container") {
95        return true;
96    }
97    KNOWN_CONTAINER_NAMES.contains(&item_name)
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn detects_via_item_type() {
106        assert!(is_inventory_container_item("Base Grade Container", "Some Random Name", std::iter::empty::<(&str, &str)>()));
107    }
108
109    #[test]
110    fn detects_via_tag() {
111        assert!(is_inventory_container_item("", "Some Random Name", [("Type", "Container")]));
112        assert!(!is_inventory_container_item("", "Some Random Name", [("Quality", "Container")]));
113        assert!(!is_inventory_container_item("", "Some Random Name", [("Type", "Weapon")]));
114    }
115
116    /// Mirrors the canonical trade-history tag shape — exactly one tag with
117    /// `category == "Type"` and `name == "Container"` must classify the item.
118    #[test]
119    fn detects_via_single_canonical_tag() {
120        // Multiple tags, only one matches → still a container.
121        let tags = [
122            ("Rarity", "Mil-Spec"), // noise
123            ("Quality", "Normal"),  // noise
124            ("Type", "Container"),  // ← the canonical one
125            ("Weapon", "AK-47"),    // noise
126        ];
127        assert!(is_inventory_container_item("", "Anything", tags));
128    }
129
130    #[test]
131    fn detects_via_known_name() {
132        assert!(is_inventory_container_item("", "Kilowatt Case", std::iter::empty::<(&str, &str)>()));
133        assert!(!is_inventory_container_item("", "AK-47 | Redline", std::iter::empty::<(&str, &str)>()));
134    }
135
136    #[test]
137    fn rejects_non_container() {
138        assert!(!is_inventory_container_item("Rifle", "AK-47 | Redline", [("Type", "Weapon")]));
139    }
140}