Skip to main content

oxyde_intent/
lib.rs

1//! Intent understanding for player interactions
2//!
3//! This module provides functionality for understanding player intent from
4//! their actions, chat messages, and other interactions.
5
6use std::collections::HashSet;
7
8use serde::{Deserialize, Serialize};
9
10use oxyde_core::{OxydeError, Result};
11
12/// Type of player intent
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum IntentType {
16    /// Player is asking a question
17    Question,
18    /// Player is greeting the NPC
19    Greeting,
20    /// Player is issuing a command
21    Command,
22    /// General chat/conversation
23    Chat,
24    /// Proximity-based intent (player approaching/nearby)
25    Proximity,
26    /// Friendly/positive interaction
27    Friendly,
28    /// Hostile/aggressive interaction
29    Hostile,
30    /// Threat or intimidation
31    Threat,
32    /// Making a request
33    Request,
34    /// Making a demand
35    Demand,
36    /// Query or question (alias for Question)
37    Query,
38    /// Custom/unknown intent type
39    Custom,
40}
41
42impl IntentType {
43    /// Convert from string representation
44    pub fn from_str(s: &str) -> Self {
45        match s.to_lowercase().as_str() {
46            "question" | "query" => Self::Question,
47            "greeting" => Self::Greeting,
48            "command" => Self::Command,
49            "chat" => Self::Chat,
50            "proximity" => Self::Proximity,
51            "friendly" => Self::Friendly,
52            "hostile" => Self::Hostile,
53            "threat" => Self::Threat,
54            "request" => Self::Request,
55            "demand" => Self::Demand,
56            _ => Self::Custom,
57        }
58    }
59
60    /// Convert to string representation
61    pub fn as_str(&self) -> &'static str {
62        match self {
63            Self::Question => "question",
64            Self::Greeting => "greeting",
65            Self::Command => "command",
66            Self::Chat => "chat",
67            Self::Proximity => "proximity",
68            Self::Friendly => "friendly",
69            Self::Hostile => "hostile",
70            Self::Threat => "threat",
71            Self::Request => "request",
72            Self::Demand => "demand",
73            Self::Query => "query",
74            Self::Custom => "custom",
75        }
76    }
77}
78
79impl std::fmt::Display for IntentType {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        write!(f, "{}", self.as_str())
82    }
83}
84
85/// Intent represents the player's intended action or request
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Intent {
88    /// Type of intent
89    pub intent_type: IntentType,
90
91    /// Confidence score for the intent classification (0.0 - 1.0)
92    pub confidence: f64,
93
94    /// Raw input from the player
95    pub raw_input: String,
96
97    /// Keywords extracted from the input
98    pub keywords: Vec<String>,
99}
100
101impl Intent {
102    /// Create a new intent
103    ///
104    /// # Arguments
105    ///
106    /// * `intent_type` - Type of intent
107    /// * `confidence` - Confidence score
108    /// * `raw_input` - Raw input from the player
109    /// * `keywords` - Keywords extracted from the input
110    ///
111    /// # Returns
112    ///
113    /// A new Intent instance
114    pub fn new(
115        intent_type: IntentType,
116        confidence: f64,
117        raw_input: &str,
118        keywords: Vec<String>,
119    ) -> Self {
120        Self {
121            intent_type,
122            confidence: confidence.clamp(0.0, 1.0),
123            raw_input: raw_input.to_string(),
124            keywords,
125        }
126    }
127    
128    /// Create a proximity intent
129    ///
130    /// # Arguments
131    ///
132    /// * `distance` - Distance to the player
133    ///
134    /// # Returns
135    ///
136    /// A proximity Intent
137    pub fn proximity(distance: f32) -> Self {
138        Self::new(
139            IntentType::Proximity,
140            1.0,
141            "",
142            vec![format!("distance:{}", distance)],
143        )
144    }
145    
146    /// Create an intent from player chat
147    ///
148    /// # Arguments
149    ///
150    /// * `text` - Player's chat message
151    ///
152    /// # Returns
153    ///
154    /// An Intent based on the chat message
155    pub fn from_chat(text: &str) -> Self {
156        // Extract keywords from the text
157        let keywords = Self::extract_keywords(text);
158
159        // Determine intent type
160        let intent_type = if text.ends_with("?") {
161            IntentType::Question
162        } else if Self::is_greeting(text) {
163            IntentType::Greeting
164        } else if Self::is_command(text) {
165            IntentType::Command
166        } else {
167            IntentType::Chat
168        };
169
170        Self::new(
171            intent_type,
172            0.8, // Confidence score
173            text,
174            keywords,
175        )
176    }
177    
178    /// Analyze player input to determine intent
179    ///
180    /// # Arguments
181    ///
182    /// * `input` - Raw player input
183    ///
184    /// # Returns
185    ///
186    /// An Intent based on the input
187    pub async fn analyze(input: &str) -> Result<Self> {
188        if input.is_empty() {
189            return Err(OxydeError::IntentError("Empty input".to_string()));
190        }
191        
192        // Simple rule-based intent classification
193        // In a real implementation, this would use more sophisticated NLP
194        Ok(Self::from_chat(input))
195    }
196    
197    /// Extract keywords from text
198    ///
199    /// # Arguments
200    ///
201    /// * `text` - Text to extract keywords from
202    ///
203    /// # Returns
204    ///
205    /// Vector of extracted keywords
206    pub fn extract_keywords(text: &str) -> Vec<String> {
207        let mut keywords = Vec::new();
208        let stopwords: HashSet<&str> = [
209            "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for",
210            "with", "by", "about", "against", "between", "into", "through",
211            "is", "are", "was", "were", "be", "been", "being",
212            "i", "you", "he", "she", "it", "we", "they",
213            "my", "your", "his", "her", "its", "our", "their",
214        ].iter().cloned().collect();
215        
216        for word in text.split_whitespace() {
217            // Remove punctuation from the word
218            let clean_word = word.trim_matches(|c: char| !c.is_alphanumeric()).to_lowercase();
219            if clean_word.len() > 2 && !stopwords.contains(clean_word.as_str()) {
220                keywords.push(clean_word);
221            }
222        }
223        
224        keywords
225    }
226    
227    /// Check if text is a greeting
228    ///
229    /// # Arguments
230    ///
231    /// * `text` - Text to check
232    ///
233    /// # Returns
234    ///
235    /// Whether the text is a greeting
236    fn is_greeting(text: &str) -> bool {
237        let greetings = [
238            "hello", "hi", "hey", "greetings", "good morning",
239            "good afternoon", "good evening", "howdy", "sup",
240            "what's up", "hiya",
241        ];
242        
243        let text_lower = text.to_lowercase();
244        // Check if the text starts with a greeting or contains it as a whole word
245        greetings.iter().any(|g| {
246            text_lower.starts_with(g) || 
247            text_lower.split_whitespace().any(|word| word == *g)
248        })
249    }
250    
251    /// Check if text is a command
252    ///
253    /// # Arguments
254    ///
255    /// * `text` - Text to check
256    ///
257    /// # Returns
258    ///
259    /// Whether the text is a command
260    fn is_command(text: &str) -> bool {
261        let command_prefixes = [
262            "follow", "go", "attack", "defend", "run", "wait",
263            "stop", "help", "give", "take", "use", "open",
264            "close", "find", "look", "examine", "talk",
265        ];
266        
267        let text_lower = text.to_lowercase();
268        command_prefixes.iter().any(|c| text_lower.starts_with(c))
269    }
270    
271    /// Check if the intent has a specific keyword
272    ///
273    /// # Arguments
274    ///
275    /// * `keyword` - Keyword to check for
276    ///
277    /// # Returns
278    ///
279    /// Whether the intent contains the keyword
280    pub fn has_keyword(&self, keyword: &str) -> bool {
281        self.keywords.iter().any(|k| k == keyword)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    
289    #[test]
290    fn test_intent_from_chat() {
291        let greeting = Intent::from_chat("Hello there!");
292        assert_eq!(greeting.intent_type, IntentType::Greeting);
293
294        let question = Intent::from_chat("What is your name?");
295        assert_eq!(question.intent_type, IntentType::Question);
296
297        let command = Intent::from_chat("follow me");
298        assert_eq!(command.intent_type, IntentType::Command);
299
300        let chat = Intent::from_chat("I like this village.");
301        assert_eq!(chat.intent_type, IntentType::Chat);
302    }
303    
304    #[test]
305    fn test_keyword_extraction() {
306        let keywords = Intent::extract_keywords("What is the capital of France?");
307        assert!(keywords.contains(&"capital".to_string()));
308        assert!(keywords.contains(&"france".to_string()));
309        assert!(!keywords.contains(&"is".to_string())); // Stopword should be filtered
310    }
311}