Skip to main content

thread_structured_id/
lib.rs

1//! # `thread-id` — the StructuredId scheme for the Thread
2//!
3//! A compact, human-readable id for renderable prefabs and entities on the Thread:
4//! `CCSSNNNN` (8 decimal digits) packed into a `u32` — category / subcategory /
5//! number. It (de)serializes as either the 8-digit string `"21010023"` or the raw
6//! number `21010023`, so a World Manifest can key prefabs by a stable id.
7//!
8//! This crate is deliberately tiny and dependency-light (only `serde`) so the
9//! neutral [World Manifest](https://github.com/Pixygon/Infinite) format can depend
10//! on the id scheme without pulling in any engine or game code. The Pixygon-specific
11//! *category meanings* (which range is Actors, Items, …) live in the engine, not
12//! here — this crate is just the encoding.
13
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15use std::fmt;
16
17/// A structured decimal ID encoding category, subcategory, and item number.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct StructuredId(pub u32);
20
21impl StructuredId {
22    /// Create a new structured ID from components.
23    ///
24    /// # Panics
25    /// Panics if category is 0 or >99, subcategory >99, or number is 0 or >9999.
26    pub fn new(category: u8, subcategory: u8, number: u16) -> Self {
27        assert!((1..=99).contains(&category), "category must be 1-99");
28        assert!(subcategory <= 99, "subcategory must be 0-99");
29        assert!((1..=9999).contains(&number), "number must be 1-9999");
30        Self(category as u32 * 1_000_000 + subcategory as u32 * 10_000 + number as u32)
31    }
32
33    /// Try to create a structured ID, returning None if components are out of range.
34    pub fn try_new(category: u8, subcategory: u8, number: u16) -> Option<Self> {
35        if !(1..=99).contains(&category) || subcategory > 99 || !(1..=9999).contains(&number) {
36            return None;
37        }
38        Some(Self(
39            category as u32 * 1_000_000 + subcategory as u32 * 10_000 + number as u32,
40        ))
41    }
42
43    /// Extract the category (CC) component.
44    pub fn category(&self) -> u8 {
45        (self.0 / 1_000_000) as u8
46    }
47
48    /// Extract the subcategory (SS) component.
49    pub fn subcategory(&self) -> u8 {
50        ((self.0 / 10_000) % 100) as u8
51    }
52
53    /// Extract the item number (NNNN) component.
54    pub fn number(&self) -> u16 {
55        (self.0 % 10_000) as u16
56    }
57
58    /// Determine the domain based on category range.
59    pub fn domain(&self) -> IdDomain {
60        match self.category() {
61            10..=19 => IdDomain::Actors,
62            20..=29 => IdDomain::Items,
63            30..=39 => IdDomain::Skills,
64            40..=49 => IdDomain::World,
65            _ => IdDomain::Unknown,
66        }
67    }
68
69    /// Format as zero-padded 8-digit string "CCSSNNNN".
70    pub fn to_id_string(&self) -> String {
71        format!("{:08}", self.0)
72    }
73
74    /// Parse from an 8-digit string "CCSSNNNN".
75    pub fn parse(s: &str) -> Option<Self> {
76        let val: u32 = s.parse().ok()?;
77        Self::from_raw(val)
78    }
79
80    /// Create from a raw u32, validating the encoded components.
81    pub fn from_raw(val: u32) -> Option<Self> {
82        let id = Self(val);
83        let cat = id.category();
84        let num = id.number();
85        if !(1..=99).contains(&cat) || !(1..=9999).contains(&num) {
86            return None;
87        }
88        Some(id)
89    }
90}
91
92impl fmt::Display for StructuredId {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        write!(f, "{:08}", self.0)
95    }
96}
97
98impl Serialize for StructuredId {
99    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
100        serializer.serialize_str(&self.to_id_string())
101    }
102}
103
104impl<'de> Deserialize<'de> for StructuredId {
105    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
106        struct IdVisitor;
107
108        impl<'de> serde::de::Visitor<'de> for IdVisitor {
109            type Value = StructuredId;
110
111            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
112                formatter.write_str("a structured ID string \"CCSSNNNN\" or a u32 number")
113            }
114
115            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<StructuredId, E> {
116                StructuredId::parse(v)
117                    .ok_or_else(|| E::custom(format!("invalid structured ID: {v}")))
118            }
119
120            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<StructuredId, E> {
121                if v > u32::MAX as u64 {
122                    return Err(E::custom(format!("structured ID too large: {v}")));
123                }
124                StructuredId::from_raw(v as u32)
125                    .ok_or_else(|| E::custom(format!("invalid structured ID value: {v}")))
126            }
127
128            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<StructuredId, E> {
129                if v < 0 || v > u32::MAX as i64 {
130                    return Err(E::custom(format!("structured ID out of range: {v}")));
131                }
132                self.visit_u64(v as u64)
133            }
134        }
135
136        deserializer.deserialize_any(IdVisitor)
137    }
138}
139
140/// High-level domain grouping derived from category ranges.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
142pub enum IdDomain {
143    /// Categories 10-19: Player characters, humanoid NPCs, creatures
144    Actors,
145    /// Categories 20-29: Weapons, armor, accessories, clothing, consumables, materials, gems, runes
146    Items,
147    /// Categories 30-39: Active and passive skills
148    Skills,
149    /// Categories 40-49: Doors, containers, levers, portals, signs
150    World,
151    /// Category outside defined ranges
152    Unknown,
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn encodes_and_decodes_components() {
161        let id = StructuredId::new(21, 1, 23);
162        assert_eq!(id.0, 21_01_0023);
163        assert_eq!(id.category(), 21);
164        assert_eq!(id.subcategory(), 1);
165        assert_eq!(id.number(), 23);
166        assert_eq!(id.to_id_string(), "21010023");
167    }
168
169    #[test]
170    fn parses_and_validates_raw() {
171        assert_eq!(
172            StructuredId::parse("21010023"),
173            Some(StructuredId(21010023))
174        );
175        assert!(StructuredId::from_raw(0).is_none(), "category 0 is invalid");
176        assert!(StructuredId::try_new(0, 0, 1).is_none());
177    }
178
179    #[test]
180    fn domain_follows_category_ranges() {
181        assert_eq!(StructuredId::new(11, 0, 1).domain(), IdDomain::Actors);
182        assert_eq!(StructuredId::new(21, 0, 1).domain(), IdDomain::Items);
183        assert_eq!(StructuredId::new(99, 0, 1).domain(), IdDomain::Unknown);
184    }
185
186    #[test]
187    fn serde_accepts_string_or_number() {
188        let from_str: StructuredId = serde_json::from_str("\"21010023\"").unwrap();
189        let from_num: StructuredId = serde_json::from_str("21010023").unwrap();
190        assert_eq!(from_str, from_num);
191        // Serializes back to the canonical 8-digit string.
192        assert_eq!(serde_json::to_string(&from_str).unwrap(), "\"21010023\"");
193    }
194}