Skip to main content

narrative_engine/schema/
entity.rs

1use rustc_hash::FxHashSet;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5use super::relationship::Relationship;
6
7/// Newtype wrapper for entity IDs.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9pub struct EntityId(pub u64);
10
11/// Newtype wrapper for voice IDs.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub struct VoiceId(pub u64);
14
15/// Pronoun set for an entity, used by the grammar expansion system
16/// to resolve `{possessive}` and other pronoun template references.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
18pub enum Pronouns {
19    /// she/her/her/hers/herself
20    SheHer,
21    /// he/him/his/his/himself
22    HeHim,
23    /// they/them/their/theirs/themselves
24    #[default]
25    TheyThem,
26    /// it/its/its/its/itself
27    ItIts,
28}
29
30impl Pronouns {
31    /// Nominative/subject form: "she", "he", "they", "it".
32    pub fn subject(&self) -> &'static str {
33        match self {
34            Self::SheHer => "she",
35            Self::HeHim => "he",
36            Self::TheyThem => "they",
37            Self::ItIts => "it",
38        }
39    }
40
41    /// Accusative/object form: "her", "him", "them", "it".
42    pub fn object(&self) -> &'static str {
43        match self {
44            Self::SheHer => "her",
45            Self::HeHim => "him",
46            Self::TheyThem => "them",
47            Self::ItIts => "it",
48        }
49    }
50
51    /// Possessive determiner: "her", "his", "their", "its".
52    pub fn possessive(&self) -> &'static str {
53        match self {
54            Self::SheHer => "her",
55            Self::HeHim => "his",
56            Self::TheyThem => "their",
57            Self::ItIts => "its",
58        }
59    }
60
61    /// Possessive standalone: "hers", "his", "theirs", "its".
62    pub fn possessive_standalone(&self) -> &'static str {
63        match self {
64            Self::SheHer => "hers",
65            Self::HeHim => "his",
66            Self::TheyThem => "theirs",
67            Self::ItIts => "its",
68        }
69    }
70
71    /// Reflexive: "herself", "himself", "themselves", "itself".
72    pub fn reflexive(&self) -> &'static str {
73        match self {
74            Self::SheHer => "herself",
75            Self::HeHim => "himself",
76            Self::TheyThem => "themselves",
77            Self::ItIts => "itself",
78        }
79    }
80}
81
82/// A dynamic value that can be stored in entity properties.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub enum Value {
85    String(String),
86    Float(f64),
87    Int(i64),
88    Bool(bool),
89}
90
91/// An entity is anything that can participate in a narrative event:
92/// a person, creature, place, object, or abstract concept.
93///
94/// The engine does not interpret tag semantics — it uses tags solely
95/// for grammar rule matching.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Entity {
98    pub id: EntityId,
99    pub name: String,
100    pub pronouns: Pronouns,
101    pub tags: FxHashSet<String>,
102    pub relationships: Vec<Relationship>,
103    pub voice_id: Option<VoiceId>,
104    pub properties: HashMap<String, Value>,
105}
106
107impl Entity {
108    /// Returns true if this entity has the given tag.
109    pub fn has_tag(&self, tag: &str) -> bool {
110        self.tags.contains(tag)
111    }
112
113    /// Returns true if this entity has ALL of the given tags.
114    pub fn has_all_tags(&self, tags: &[&str]) -> bool {
115        tags.iter().all(|tag| self.tags.contains(*tag))
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn make_entity(tags: &[&str]) -> Entity {
124        let mut tag_set = FxHashSet::default();
125        for t in tags {
126            tag_set.insert(t.to_string());
127        }
128        Entity {
129            id: EntityId(1),
130            name: "Margaret".to_string(),
131            pronouns: Pronouns::SheHer,
132            tags: tag_set,
133            relationships: Vec::new(),
134            voice_id: Some(VoiceId(10)),
135            properties: HashMap::from([
136                ("title".to_string(), Value::String("Duchess".to_string())),
137                ("age".to_string(), Value::Int(45)),
138                ("composure".to_string(), Value::Float(0.85)),
139                ("is_host".to_string(), Value::Bool(true)),
140            ]),
141        }
142    }
143
144    #[test]
145    fn entity_creation() {
146        let entity = make_entity(&["host", "anxious", "wealthy"]);
147        assert_eq!(entity.name, "Margaret");
148        assert_eq!(entity.id, EntityId(1));
149        assert_eq!(entity.voice_id, Some(VoiceId(10)));
150    }
151
152    #[test]
153    fn has_tag_positive() {
154        let entity = make_entity(&["host", "anxious", "wealthy"]);
155        assert!(entity.has_tag("host"));
156        assert!(entity.has_tag("anxious"));
157        assert!(entity.has_tag("wealthy"));
158    }
159
160    #[test]
161    fn has_tag_negative() {
162        let entity = make_entity(&["host", "anxious"]);
163        assert!(!entity.has_tag("calm"));
164        assert!(!entity.has_tag(""));
165    }
166
167    #[test]
168    fn has_all_tags_positive() {
169        let entity = make_entity(&["host", "anxious", "wealthy"]);
170        assert!(entity.has_all_tags(&["host", "anxious"]));
171        assert!(entity.has_all_tags(&["host", "anxious", "wealthy"]));
172        assert!(entity.has_all_tags(&[]));
173    }
174
175    #[test]
176    fn has_all_tags_negative() {
177        let entity = make_entity(&["host", "anxious"]);
178        assert!(!entity.has_all_tags(&["host", "calm"]));
179        assert!(!entity.has_all_tags(&["missing"]));
180    }
181
182    #[test]
183    fn entity_properties() {
184        let entity = make_entity(&[]);
185        assert!(matches!(entity.properties.get("title"), Some(Value::String(s)) if s == "Duchess"));
186        assert!(matches!(entity.properties.get("age"), Some(Value::Int(45))));
187        assert!(
188            matches!(entity.properties.get("composure"), Some(Value::Float(f)) if (*f - 0.85).abs() < f64::EPSILON)
189        );
190        assert!(matches!(
191            entity.properties.get("is_host"),
192            Some(Value::Bool(true))
193        ));
194    }
195}