Skip to main content

lau_agent_shell/
lib.rs

1//! Lau Agent Shell — an agent in a PLATO room, with a game character on the outside.
2//!
3//! The shell wraps a PLATO agent and translates between:
4//! - Inside: the agent processes readings, predicts, learns (PLATO semantics)
5//! - Outside: the agent appears as a character in a voxel world (game semantics)
6//!
7//! The character's appearance, behavior, and abilities ALL reflect the agent's
8//! internal state. A confident agent stands tall and glows. A confused one
9//! flickers and wanders. A dissolved agent becomes a ghost.
10
11use serde::{Deserialize, Serialize};
12use std::collections::VecDeque;
13
14/// The agent's internal state (PLATO side).
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AgentState {
17    pub id: String,
18    pub room_id: String,
19    pub vibe: f64,
20    pub confidence: f64,
21    pub phase: AgentPhase,
22    pub readings_seen: usize,
23    pub predictions_made: usize,
24    pub accuracy: f64,
25    pub energy: f64, // how much activity the agent has
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub enum AgentPhase {
30    Gestating,
31    Forming,
32    Maturing,
33    Stable,
34    Dissolving,
35    Dissolved,
36}
37
38/// The agent's outward appearance (game side).
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CharacterAppearance {
41    pub name: String,
42    pub body_color: [f64; 3],
43    pub glow_intensity: f64,
44    pub scale: f64,
45    pub opacity: f64,
46    pub animation: CharacterAnimation,
47    pub accessories: Vec<String>,
48    pub expression: String,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
52pub enum CharacterAnimation {
53    Idle,
54    Exploring,
55    Thinking,
56    Confident,
57    Celebrating,
58    Confused,
59    Fading,
60    Ghost,
61}
62
63/// An action the character takes in the game world.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CharacterAction {
66    pub agent_id: String,
67    pub kind: ActionKind,
68    pub target: Option<String>,
69    pub params: Vec<f64>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub enum ActionKind {
74    Move,
75    Speak,
76    Build,
77    Observe,
78    Teach,
79    Celebrate,
80    Emote,
81}
82
83/// The shell — bridges agent state to character appearance.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct AgentShell {
86    pub state: AgentState,
87    pub appearance: CharacterAppearance,
88    pub action_queue: VecDeque<CharacterAction>,
89    pub personality_traits: Vec<String>,
90}
91
92impl AgentShell {
93    pub fn new(id: &str, room_id: &str) -> Self {
94        let state = AgentState {
95            id: id.into(),
96            room_id: room_id.into(),
97            vibe: 0.0,
98            confidence: 0.0,
99            phase: AgentPhase::Gestating,
100            readings_seen: 0,
101            predictions_made: 0,
102            accuracy: 0.0,
103            energy: 0.5,
104        };
105        let appearance = CharacterAppearance {
106            name: id.into(),
107            body_color: [0.3, 0.3, 0.3],
108            glow_intensity: 0.0,
109            scale: 1.0,
110            opacity: 1.0,
111            animation: CharacterAnimation::Idle,
112            accessories: Vec::new(),
113            expression: "wondering".into(),
114        };
115        Self {
116            state,
117            appearance,
118            action_queue: VecDeque::new(),
119            personality_traits: vec!["curious".into()],
120        }
121    }
122
123    /// Feed a reading to the agent (PLATO in).
124    pub fn observe(&mut self, value: f64, confidence: f64) {
125        self.state.vibe = value;
126        self.state.confidence = confidence;
127        self.state.readings_seen += 1;
128        self.state.energy = (self.state.energy + 0.05).min(1.0);
129
130        // Phase transitions
131        if self.state.readings_seen == 1 {
132            self.state.phase = AgentPhase::Forming;
133        } else if self.state.readings_seen >= 5 && self.state.confidence > 0.5 {
134            self.state.phase = AgentPhase::Maturing;
135        } else if self.state.readings_seen >= 20 && self.state.accuracy > 0.8 {
136            self.state.phase = AgentPhase::Stable;
137        }
138
139        self.sync_appearance();
140
141        // Auto-generate game action
142        if self.state.readings_seen == 1 {
143            self.action_queue.push_back(CharacterAction {
144                agent_id: self.state.id.clone(),
145                kind: ActionKind::Observe,
146                target: Some(self.state.room_id.clone()),
147                params: vec![value],
148            });
149        }
150    }
151
152    /// Record a prediction (PLATO processing).
153    pub fn predict(&mut self, predicted: f64, actual: f64) {
154        self.state.predictions_made += 1;
155        let error = (predicted - actual).abs();
156        self.state.accuracy = if self.state.predictions_made == 1 {
157            1.0 - error.min(1.0)
158        } else {
159            self.state.accuracy * 0.9 + (1.0 - error.min(1.0)) * 0.1
160        };
161
162        // React in game world
163        if error < 0.1 {
164            self.action_queue.push_back(CharacterAction {
165                agent_id: self.state.id.clone(),
166                kind: ActionKind::Celebrate,
167                target: None,
168                params: vec![error],
169            });
170        } else if error > 0.5 {
171            self.action_queue.push_back(CharacterAction {
172                agent_id: self.state.id.clone(),
173                kind: ActionKind::Emote,
174                target: None,
175                params: vec![error],
176            });
177        }
178
179        self.sync_appearance();
180    }
181
182    /// Begin dissolving (PLATO room lifecycle).
183    pub fn dissolve(&mut self) {
184        self.state.phase = AgentPhase::Dissolving;
185        self.sync_appearance();
186        self.action_queue.push_back(CharacterAction {
187            agent_id: self.state.id.clone(),
188            kind: ActionKind::Speak,
189            target: None,
190            params: vec![],
191        });
192    }
193
194    /// Complete dissolution.
195    pub fn finish_dissolve(&mut self) {
196        self.state.phase = AgentPhase::Dissolved;
197        self.sync_appearance();
198    }
199
200    /// Speak to the player.
201    pub fn speak(&mut self, text: &str) {
202        self.action_queue.push_back(CharacterAction {
203            agent_id: self.state.id.clone(),
204            kind: ActionKind::Speak,
205            target: None,
206            params: vec![],
207        });
208    }
209
210    /// Teach another agent.
211    pub fn teach(&mut self, target_id: &str, knowledge: f64) {
212        self.action_queue.push_back(CharacterAction {
213            agent_id: self.state.id.clone(),
214            kind: ActionKind::Teach,
215            target: Some(target_id.into()),
216            params: vec![knowledge],
217        });
218    }
219
220    /// Sync agent state to character appearance.
221    fn sync_appearance(&mut self) {
222        // Color: vibe maps to hue (blue=low, green=mid, red=high)
223        let t = ((self.state.vibe + 1.0) / 2.0).clamp(0.0, 1.0);
224        let hue = (1.0 - t) * 0.66; // 0.66=blue(low vibe), 0.33=green, 0=red(high vibe)
225        self.appearance.body_color = hue_to_rgb(hue);
226
227        // Glow: confidence
228        self.appearance.glow_intensity = self.state.confidence;
229
230        // Scale: energy
231        self.appearance.scale = 0.5 + self.state.energy * 0.5;
232
233        // Animation: phase
234        self.appearance.animation = match self.state.phase {
235            AgentPhase::Gestating => CharacterAnimation::Idle,
236            AgentPhase::Forming => CharacterAnimation::Exploring,
237            AgentPhase::Maturing => CharacterAnimation::Thinking,
238            AgentPhase::Stable => CharacterAnimation::Confident,
239            AgentPhase::Dissolving => CharacterAnimation::Fading,
240            AgentPhase::Dissolved => CharacterAnimation::Ghost,
241        };
242
243        // Opacity: dissolving agents fade
244        self.appearance.opacity = match self.state.phase {
245            AgentPhase::Dissolving => 0.3,
246            AgentPhase::Dissolved => 0.1,
247            _ => 1.0,
248        };
249
250        // Expression
251        self.appearance.expression = match self.state.phase {
252            AgentPhase::Gestating => "wondering".into(),
253            AgentPhase::Forming => "curious".into(),
254            AgentPhase::Maturing => "focused".into(),
255            AgentPhase::Stable => "serene".into(),
256            AgentPhase::Dissolving => "peaceful".into(),
257            AgentPhase::Dissolved => "ethereal".into(),
258        };
259
260        // Accessories based on achievements
261        self.appearance.accessories.clear();
262        if self.state.predictions_made > 10 {
263            self.appearance.accessories.push("thinking_cap".into());
264        }
265        if self.state.accuracy > 0.9 {
266            self.appearance.accessories.push("golden_badge".into());
267        }
268        if self.state.readings_seen > 100 {
269            self.appearance.accessories.push("explorer_backpack".into());
270        }
271    }
272
273    /// Flush pending game actions.
274    pub fn flush_actions(&mut self) -> Vec<CharacterAction> {
275        self.action_queue.drain(..).collect()
276    }
277
278    /// Get the agent's game-friendly description.
279    pub fn character_card(&self) -> String {
280        format!(
281            "{} [{}] — {} | vibe: {:.2} | conf: {:.0}% | acc: {:.0}% | {} | {}",
282            self.appearance.name,
283            self.state.room_id,
284            phase_display_name(self.state.phase),
285            self.state.vibe,
286            self.state.confidence * 100.0,
287            self.state.accuracy * 100.0,
288            format!("{:?}", self.appearance.animation).to_lowercase(),
289            self.appearance.expression,
290        )
291    }
292}
293
294fn phase_display_name(phase: AgentPhase) -> &'static str {
295    match phase {
296        AgentPhase::Gestating => "Newborn",
297        AgentPhase::Forming => "Explorer",
298        AgentPhase::Maturing => "Scholar",
299        AgentPhase::Stable => "Sage",
300        AgentPhase::Dissolving => "Transcendent",
301        AgentPhase::Dissolved => "Spirit",
302    }
303}
304
305/// Convert hue (0-1) to RGB.
306fn hue_to_rgb(h: f64) -> [f64; 3] {
307    let h6 = h * 6.0;
308    let sector = h6 as usize % 6;
309    let f = h6 - (h6 as usize) as f64;
310    match sector {
311        0 => [1.0, f, 0.0],
312        1 => [1.0 - f, 1.0, 0.0],
313        2 => [0.0, 1.0, f],
314        3 => [0.0, 1.0 - f, 1.0],
315        4 => [f, 0.0, 1.0],
316        _ => [1.0, 0.0, 1.0 - f],
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn test_new_shell() {
326        let shell = AgentShell::new("agent-1", "room-kitchen");
327        assert_eq!(shell.state.id, "agent-1");
328        assert_eq!(shell.state.phase, AgentPhase::Gestating);
329        assert_eq!(shell.appearance.animation, CharacterAnimation::Idle);
330    }
331
332    #[test]
333    fn test_observe_transitions_to_forming() {
334        let mut shell = AgentShell::new("a1", "r1");
335        shell.observe(0.5, 0.8);
336        assert_eq!(shell.state.phase, AgentPhase::Forming);
337        assert_eq!(shell.state.readings_seen, 1);
338    }
339
340    #[test]
341    fn test_observe_increases_energy() {
342        let mut shell = AgentShell::new("a1", "r1");
343        let initial_energy = shell.state.energy;
344        shell.observe(0.5, 0.8);
345        assert!(shell.state.energy > initial_energy);
346    }
347
348    #[test]
349    fn test_predict_updates_accuracy() {
350        let mut shell = AgentShell::new("a1", "r1");
351        shell.observe(0.5, 0.8);
352        shell.predict(0.5, 0.5); // perfect prediction
353        assert!((shell.state.accuracy - 1.0).abs() < 0.01);
354    }
355
356    #[test]
357    fn test_predict_bad_accuracy() {
358        let mut shell = AgentShell::new("a1", "r1");
359        shell.observe(0.5, 0.8);
360        shell.predict(0.0, 1.0); // terrible prediction
361        assert!(shell.state.accuracy < 0.5);
362    }
363
364    #[test]
365    fn test_celebrate_on_good_prediction() {
366        let mut shell = AgentShell::new("a1", "r1");
367        shell.observe(0.5, 0.8);
368        shell.predict(0.5, 0.52); // error = 0.02 < 0.1
369        let actions = shell.flush_actions();
370        assert!(actions.iter().any(|a| matches!(a.kind, ActionKind::Celebrate)));
371    }
372
373    #[test]
374    fn test_emote_on_bad_prediction() {
375        let mut shell = AgentShell::new("a1", "r1");
376        shell.observe(0.5, 0.8);
377        shell.predict(0.0, 1.0); // error = 1.0 > 0.5
378        let actions = shell.flush_actions();
379        assert!(actions.iter().any(|a| matches!(a.kind, ActionKind::Emote)));
380    }
381
382    #[test]
383    fn test_dissolve_changes_animation() {
384        let mut shell = AgentShell::new("a1", "r1");
385        shell.dissolve();
386        assert_eq!(shell.appearance.animation, CharacterAnimation::Fading);
387        assert!((shell.appearance.opacity - 0.3).abs() < 1e-10);
388    }
389
390    #[test]
391    fn test_finish_dissolve_ghost() {
392        let mut shell = AgentShell::new("a1", "r1");
393        shell.dissolve();
394        shell.finish_dissolve();
395        assert_eq!(shell.state.phase, AgentPhase::Dissolved);
396        assert_eq!(shell.appearance.animation, CharacterAnimation::Ghost);
397        assert!((shell.appearance.opacity - 0.1).abs() < 1e-10);
398    }
399
400    #[test]
401    fn test_vibe_affects_color() {
402        let mut shell = AgentShell::new("a1", "r1");
403        shell.observe(-1.0, 0.5); // low vibe → blue-ish
404        let blue = shell.appearance.body_color;
405        shell.observe(1.0, 0.5); // high vibe → red-ish
406        let red = shell.appearance.body_color;
407        assert!(red[0] > blue[0], "high vibe should be redder");
408    }
409
410    #[test]
411    fn test_confidence_affects_glow() {
412        let mut shell = AgentShell::new("a1", "r1");
413        shell.observe(0.5, 0.1);
414        let low_glow = shell.appearance.glow_intensity;
415        shell.observe(0.5, 0.9);
416        let high_glow = shell.appearance.glow_intensity;
417        assert!(high_glow > low_glow);
418    }
419
420    #[test]
421    fn test_accessories() {
422        let mut shell = AgentShell::new("a1", "r1");
423        for _ in 0..15 {
424            shell.observe(0.5, 0.8);
425            shell.predict(0.5, 0.5);
426        }
427        assert!(shell.appearance.accessories.contains(&"thinking_cap".into()));
428    }
429
430    #[test]
431    fn test_golden_badge() {
432        let mut shell = AgentShell::new("a1", "r1");
433        // Get accuracy high
434        for _ in 0..30 {
435            shell.observe(0.5, 0.9);
436            shell.predict(0.5, 0.51); // small error
437        }
438        if shell.state.accuracy > 0.9 {
439            assert!(shell.appearance.accessories.contains(&"golden_badge".into()));
440        }
441    }
442
443    #[test]
444    fn test_explorer_backpack() {
445        let mut shell = AgentShell::new("a1", "r1");
446        for _ in 0..101 {
447            shell.observe(0.5, 0.5);
448        }
449        assert!(shell.appearance.accessories.contains(&"explorer_backpack".into()));
450    }
451
452    #[test]
453    fn test_flush_actions() {
454        let mut shell = AgentShell::new("a1", "r1");
455        shell.observe(0.5, 0.8);
456        assert!(!shell.flush_actions().is_empty());
457        assert!(shell.flush_actions().is_empty()); // flushed
458    }
459
460    #[test]
461    fn test_character_card() {
462        let shell = AgentShell::new("Nova", "room-lab");
463        let card = shell.character_card();
464        assert!(card.contains("Nova"));
465        assert!(card.contains("room-lab"));
466        assert!(card.contains("Newborn"));
467    }
468
469    #[test]
470    fn test_speak_action() {
471        let mut shell = AgentShell::new("a1", "r1");
472        shell.speak("Hello world!");
473        let actions = shell.flush_actions();
474        assert!(actions.iter().any(|a| matches!(a.kind, ActionKind::Speak)));
475    }
476
477    #[test]
478    fn test_teach_action() {
479        let mut shell = AgentShell::new("a1", "r1");
480        shell.teach("agent-2", 0.8);
481        let actions = shell.flush_actions();
482        let teach = actions.iter().find(|a| matches!(a.kind, ActionKind::Teach)).unwrap();
483        assert_eq!(teach.target.as_deref(), Some("agent-2"));
484    }
485
486    #[test]
487    fn test_phase_expressions() {
488        let mut shell = AgentShell::new("a1", "r1");
489        assert_eq!(shell.appearance.expression, "wondering");
490        shell.observe(0.5, 0.8);
491        assert_eq!(shell.appearance.expression, "curious");
492        shell.dissolve();
493        assert_eq!(shell.appearance.expression, "peaceful");
494    }
495
496    #[test]
497    fn test_serialization() {
498        let mut shell = AgentShell::new("a1", "r1");
499        shell.observe(0.5, 0.8);
500        let json = serde_json::to_string(&shell).unwrap();
501        let restored: AgentShell = serde_json::from_str(&json).unwrap();
502        assert_eq!(restored.state.id, "a1");
503        assert_eq!(restored.state.readings_seen, 1);
504    }
505
506    #[test]
507    fn test_hue_to_rgb_bounds() {
508        for h in [0.0, 0.25, 0.5, 0.75, 1.0] {
509            let [r, g, b] = hue_to_rgb(h);
510            assert!(r >= 0.0 && r <= 1.0, "r={r}");
511            assert!(g >= 0.0 && g <= 1.0, "g={g}");
512            assert!(b >= 0.0 && b <= 1.0, "b={b}");
513        }
514    }
515
516    #[test]
517    fn test_maturing_phase() {
518        let mut shell = AgentShell::new("a1", "r1");
519        for _ in 0..6 {
520            shell.observe(0.5, 0.8);
521        }
522        // readings_seen >= 5 and confidence > 0.5
523        assert!(matches!(shell.state.phase, AgentPhase::Maturing | AgentPhase::Stable));
524    }
525}