Skip to main content

thread_avatar/
races.rs

1//! Canonical Pixygon races, grounded in the Codex.
2//!
3//! A race is a *body mode* — a set of biology-slot defaults (see
4//! [`AvatarRaceMode`]). These four are the canonical playable species from the
5//! universe Codex:
6//! - **Humen** — the baseline / quintessence-race (smooth skin, no exotic parts).
7//! - **Hedningr** (Reptillian) — the reptiles: scale skin, tail, claws, horns.
8//! - **Arra** — the beast-race: fur coat, claws, snout, beast ears, tail.
9//! - **Draumander** (Skyfolk) — the bird-race: feathered skin, wings, beak snout.
10//!
11//! Part ids here are placeholders in the avatar id space (category 50); real
12//! authored parts (meshes/sprites) replace them via the catalog later. The
13//! *structure* — which biology slots each race fills — is the canon-faithful bit.
14
15use crate::{AvatarCatalog, AvatarPart, AvatarRaceMode, AvatarSlot};
16use thread_id::StructuredId;
17
18/// Category for avatar parts in the structured-id space.
19pub const CAT_AVATAR_PART: u8 = 50;
20/// Category for avatar race modes.
21pub const CAT_AVATAR_RACE: u8 = 51;
22
23// Biology part ids (subcategory = body-feature kind, number = variant).
24// Matches StructuredId's CCSSNNNN decimal encoding (category 50 = avatar part).
25mod ids {
26    use super::CAT_AVATAR_PART;
27    const fn part(sub: u32, num: u32) -> u32 {
28        CAT_AVATAR_PART as u32 * 1_000_000 + sub * 10_000 + num
29    }
30    pub const SKIN_SMOOTH: u32 = part(1, 1);
31    pub const SKIN_SCALE: u32 = part(1, 2);
32    pub const SKIN_FUR: u32 = part(1, 3);
33    pub const SKIN_FEATHER: u32 = part(1, 4);
34    pub const TAIL_REPTILE: u32 = part(2, 1);
35    pub const TAIL_BEAST: u32 = part(2, 2);
36    pub const CLAWS_BASIC: u32 = part(3, 1);
37    pub const COAT_FUR: u32 = part(4, 1);
38    pub const WINGS_FEATHER: u32 = part(5, 1);
39    pub const HORNS_BASIC: u32 = part(6, 1);
40    pub const SNOUT_BEAST: u32 = part(7, 1);
41    pub const SNOUT_BEAK: u32 = part(7, 2);
42    pub const EARS_BEAST: u32 = part(8, 1);
43}
44
45/// The canonical playable species.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum CanonicalRace {
48    Humen,
49    Hedningr,
50    Arra,
51    Draumander,
52}
53
54impl CanonicalRace {
55    pub const ALL: [CanonicalRace; 4] = [
56        CanonicalRace::Humen,
57        CanonicalRace::Hedningr,
58        CanonicalRace::Arra,
59        CanonicalRace::Draumander,
60    ];
61
62    /// Codex slug for this race.
63    pub fn slug(self) -> &'static str {
64        match self {
65            CanonicalRace::Humen => "humen",
66            CanonicalRace::Hedningr => "reptillian",
67            CanonicalRace::Arra => "arra",
68            CanonicalRace::Draumander => "skyfolk",
69        }
70    }
71
72    /// Display name (matches the Codex `name`).
73    pub fn name(self) -> &'static str {
74        match self {
75            CanonicalRace::Humen => "Humen",
76            CanonicalRace::Hedningr => "Hedningr",
77            CanonicalRace::Arra => "Arra",
78            CanonicalRace::Draumander => "Draumander",
79        }
80    }
81
82    fn race_number(self) -> u16 {
83        match self {
84            CanonicalRace::Humen => 1,
85            CanonicalRace::Hedningr => 2,
86            CanonicalRace::Arra => 3,
87            CanonicalRace::Draumander => 4,
88        }
89    }
90
91    /// The biology defaults that make this race a distinct body mode.
92    pub fn race_mode(self) -> AvatarRaceMode {
93        let id = StructuredId::new(CAT_AVATAR_RACE, 0, self.race_number());
94        let mut race = AvatarRaceMode::new(id, self.name());
95        match self {
96            CanonicalRace::Humen => {
97                race = race.with_default(AvatarSlot::SkinType, ids::SKIN_SMOOTH);
98            }
99            CanonicalRace::Hedningr => {
100                race = race
101                    .with_default(AvatarSlot::SkinType, ids::SKIN_SCALE)
102                    .with_default(AvatarSlot::Tail, ids::TAIL_REPTILE)
103                    .with_default(AvatarSlot::Claws, ids::CLAWS_BASIC)
104                    .with_default(AvatarSlot::Horns, ids::HORNS_BASIC);
105            }
106            CanonicalRace::Arra => {
107                race = race
108                    .with_default(AvatarSlot::SkinType, ids::SKIN_FUR)
109                    .with_default(AvatarSlot::Coat, ids::COAT_FUR)
110                    .with_default(AvatarSlot::Claws, ids::CLAWS_BASIC)
111                    .with_default(AvatarSlot::Snout, ids::SNOUT_BEAST)
112                    .with_default(AvatarSlot::Ears, ids::EARS_BEAST)
113                    .with_default(AvatarSlot::Tail, ids::TAIL_BEAST);
114            }
115            CanonicalRace::Draumander => {
116                race = race
117                    .with_default(AvatarSlot::SkinType, ids::SKIN_FEATHER)
118                    .with_default(AvatarSlot::Wings, ids::WINGS_FEATHER)
119                    .with_default(AvatarSlot::Snout, ids::SNOUT_BEAK);
120            }
121        }
122        race
123    }
124}
125
126/// Every canonical race as an [`AvatarRaceMode`].
127pub fn canonical_races() -> Vec<AvatarRaceMode> {
128    CanonicalRace::ALL.iter().map(|r| r.race_mode()).collect()
129}
130
131/// A catalog of the placeholder biology parts referenced by the canonical races,
132/// so a race-stamped [`crate::AvatarSpec`] resolves through an [`crate::AvatarBuilder`].
133pub fn biology_catalog() -> AvatarCatalog {
134    let mut cat = AvatarCatalog::new();
135    let entries: &[(u32, AvatarSlot, &str)] = &[
136        (ids::SKIN_SMOOTH, AvatarSlot::SkinType, "Smooth Skin"),
137        (ids::SKIN_SCALE, AvatarSlot::SkinType, "Scale Skin"),
138        (ids::SKIN_FUR, AvatarSlot::SkinType, "Fur Skin"),
139        (ids::SKIN_FEATHER, AvatarSlot::SkinType, "Feather Skin"),
140        (ids::TAIL_REPTILE, AvatarSlot::Tail, "Reptile Tail"),
141        (ids::TAIL_BEAST, AvatarSlot::Tail, "Beast Tail"),
142        (ids::CLAWS_BASIC, AvatarSlot::Claws, "Claws"),
143        (ids::COAT_FUR, AvatarSlot::Coat, "Fur Coat"),
144        (ids::WINGS_FEATHER, AvatarSlot::Wings, "Feathered Wings"),
145        (ids::HORNS_BASIC, AvatarSlot::Horns, "Horns"),
146        (ids::SNOUT_BEAST, AvatarSlot::Snout, "Beast Snout"),
147        (ids::SNOUT_BEAK, AvatarSlot::Snout, "Beak"),
148        (ids::EARS_BEAST, AvatarSlot::Ears, "Beast Ears"),
149    ];
150    for &(id, slot, name) in entries {
151        cat.add(AvatarPart::mesh_part(
152            StructuredId(id),
153            slot,
154            name,
155            "placeholder",
156        ));
157    }
158    cat
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::{AvatarSpec, SlotCategory};
165
166    #[test]
167    fn four_canonical_races() {
168        assert_eq!(CanonicalRace::ALL.len(), 4);
169        assert_eq!(canonical_races().len(), 4);
170        assert_eq!(CanonicalRace::Hedningr.name(), "Hedningr");
171        assert_eq!(CanonicalRace::Hedningr.slug(), "reptillian");
172        assert_eq!(CanonicalRace::Draumander.slug(), "skyfolk");
173    }
174
175    #[test]
176    fn hedningr_is_a_reptile_body() {
177        let mut spec = AvatarSpec::new();
178        CanonicalRace::Hedningr.race_mode().apply_to(&mut spec);
179        assert_eq!(spec.get(AvatarSlot::SkinType), ids::SKIN_SCALE);
180        assert!(spec.has(AvatarSlot::Tail));
181        assert!(spec.has(AvatarSlot::Claws));
182        assert!(spec.has(AvatarSlot::Horns));
183        // Reptiles have no wings.
184        assert!(!spec.has(AvatarSlot::Wings));
185    }
186
187    #[test]
188    fn draumander_has_wings() {
189        let mut spec = AvatarSpec::new();
190        CanonicalRace::Draumander.race_mode().apply_to(&mut spec);
191        assert!(spec.has(AvatarSlot::Wings));
192        assert_eq!(spec.get(AvatarSlot::Snout), ids::SNOUT_BEAK);
193    }
194
195    #[test]
196    fn humen_is_baseline() {
197        let mut spec = AvatarSpec::new();
198        CanonicalRace::Humen.race_mode().apply_to(&mut spec);
199        // Only the baseline skin; no tail/claws/wings/horns.
200        assert_eq!(spec.get(AvatarSlot::SkinType), ids::SKIN_SMOOTH);
201        for s in [
202            AvatarSlot::Tail,
203            AvatarSlot::Claws,
204            AvatarSlot::Wings,
205            AvatarSlot::Horns,
206            AvatarSlot::Coat,
207        ] {
208            assert!(!spec.has(s), "Humen should not have {s:?}");
209        }
210    }
211
212    #[test]
213    fn all_race_defaults_are_biology_and_resolve_in_catalog() {
214        let cat = biology_catalog();
215        for race in canonical_races() {
216            let mut spec = AvatarSpec::new();
217            race.apply_to(&mut spec);
218            for (slot, id) in spec.iter() {
219                assert_eq!(
220                    slot.category(),
221                    SlotCategory::Biology,
222                    "race default in non-biology slot"
223                );
224                assert!(
225                    cat.get(StructuredId(id)).is_some(),
226                    "part {id} not in biology catalog"
227                );
228            }
229        }
230    }
231}