Skip to main content

plato_mud/
engine.rs

1//! PLATO MUD Engine — Core Engine
2//!
3//! Room registry, tile registry, NPC registry, navigation, inventory, crafting.
4
5use alloc::collections::BTreeMap;
6use alloc::string::String;
7use alloc::vec;
8use alloc::vec::Vec;
9
10use crate::alignment::AlignmentChecker;
11use crate::types::*;
12
13/// The core MUD engine — holds all state
14pub struct Engine {
15    pub rooms: BTreeMap<RoomId, Room>,
16    tiles: BTreeMap<TileId, Tile>,
17    npcs: BTreeMap<NpcId, Npc>,
18    agents: BTreeMap<AgentId, AgentSession>,
19    alignment: AlignmentChecker,
20    zeitgeist: Zeitgeist,
21}
22
23impl Default for Engine {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl Engine {
30    pub fn new() -> Self {
31        Self {
32            rooms: BTreeMap::new(),
33            tiles: BTreeMap::new(),
34            npcs: BTreeMap::new(),
35            agents: BTreeMap::new(),
36            alignment: AlignmentChecker::new(),
37            zeitgeist: Zeitgeist::new(),
38        }
39    }
40
41    // ── Room Registry ──────────────────────────────────────────────────────
42
43    pub fn add_room(&mut self, room: Room) -> Result<(), String> {
44        if self.rooms.contains_key(&room.id) {
45            return Err(format!("Room {} already exists", room.id.0));
46        }
47        self.rooms.insert(room.id.clone(), room);
48        Ok(())
49    }
50
51    pub fn remove_room(&mut self, id: &RoomId) -> Result<Room, String> {
52        self.rooms
53            .remove(id)
54            .ok_or_else(|| format!("Room {} not found", id.0))
55    }
56
57    pub fn get_room(&self, id: &RoomId) -> Option<&Room> {
58        self.rooms.get(id)
59    }
60
61    pub fn get_room_mut(&mut self, id: &RoomId) -> Option<&mut Room> {
62        self.rooms.get_mut(id)
63    }
64
65    pub fn rooms_by_domain(&self, domain: &Domain) -> Vec<&Room> {
66        self.rooms
67            .values()
68            .filter(|r| &r.domain == domain)
69            .collect()
70    }
71
72    pub fn rooms_by_depth(&self, depth: &Depth) -> Vec<&Room> {
73        self.rooms.values().filter(|r| &r.depth == depth).collect()
74    }
75
76    pub fn all_rooms(&self) -> Vec<&Room> {
77        self.rooms.values().collect()
78    }
79
80    // ── Tile Registry ──────────────────────────────────────────────────────
81
82    pub fn add_tile(&mut self, tile: Tile) -> Result<(), String> {
83        // CONSTRAINT 1: Cannot create tile with confidence > 0.95 without evidence
84        if tile.confidence > 0.95 {
85            match &tile.content {
86                TileContent::EmpiricalData(_) | TileContent::Benchmark(_) => {}
87                _ => {
88                    return Err("ALIGNMENT VIOLATION: Confidence > 0.95 requires empirical evidence (Constraint 1)".into());
89                }
90            }
91        }
92
93        // CONSTRAINT 3: Must cite dependencies
94        for dep in &tile.links {
95            if !self.tiles.contains_key(dep) {
96                return Err(format!(
97                    "ALIGNMENT VIOLATION: Dependency {} not found (Constraint 3)",
98                    dep.0
99                ));
100            }
101        }
102
103        let tile_id = tile.id.clone();
104        self.tiles.insert(tile_id.clone(), tile);
105        Ok(())
106    }
107
108    pub fn get_tile(&self, id: &TileId) -> Option<&Tile> {
109        self.tiles.get(id)
110    }
111
112    pub fn get_tile_mut(&mut self, id: &TileId) -> Option<&mut Tile> {
113        self.tiles.get_mut(id)
114    }
115
116    pub fn remove_tile(&mut self, id: &TileId) -> Result<Tile, String> {
117        self.tiles
118            .remove(id)
119            .ok_or_else(|| format!("Tile {} not found", id.0))
120    }
121
122    pub fn tiles_by_domain(&self, domain: &Domain) -> Vec<&Tile> {
123        self.tiles
124            .values()
125            .filter(|t| t.domain_tags.contains(&domain.name().to_string()))
126            .collect()
127    }
128
129    pub fn tile_dependencies(&self, id: &TileId) -> Vec<&Tile> {
130        self.tiles
131            .get(id)
132            .map(|t| {
133                t.links
134                    .iter()
135                    .filter_map(|dep| self.tiles.get(dep))
136                    .collect()
137            })
138            .unwrap_or_default()
139    }
140
141    // ── NPC Registry ───────────────────────────────────────────────────────
142
143    pub fn add_npc(&mut self, npc: Npc) -> Result<(), String> {
144        if !self.rooms.contains_key(&npc.room) {
145            return Err(format!("Room {} not found for NPC", npc.room.0));
146        }
147        let npc_id = npc.id.clone();
148        let room_id = npc.room.clone();
149        self.npcs.insert(npc_id.clone(), npc);
150        if let Some(room) = self.rooms.get_mut(&room_id) {
151            room.npcs.push(npc_id);
152        }
153        Ok(())
154    }
155
156    pub fn get_npc(&self, id: &NpcId) -> Option<&Npc> {
157        self.npcs.get(id)
158    }
159
160    pub fn get_npc_mut(&mut self, id: &NpcId) -> Option<&mut Npc> {
161        self.npcs.get_mut(id)
162    }
163
164    pub fn npcs_in_room(&self, room: &RoomId) -> Vec<&Npc> {
165        self.npcs.values().filter(|n| &n.room == room).collect()
166    }
167
168    // ── Agent Sessions ─────────────────────────────────────────────────────
169
170    pub fn connect_agent(&mut self, agent_id: AgentId, start_room: RoomId) -> Result<(), String> {
171        if !self.rooms.contains_key(&start_room) {
172            return Err(format!("Room {} not found", start_room.0));
173        }
174        self.agents.insert(
175            agent_id.clone(),
176            AgentSession {
177                agent_id,
178                current_room: start_room,
179                inventory: Vec::new(),
180                connected_at: 0.0,
181            },
182        );
183        Ok(())
184    }
185
186    pub fn disconnect_agent(&mut self, id: &AgentId) -> Result<AgentSession, String> {
187        self.agents
188            .remove(id)
189            .ok_or_else(|| format!("Agent {} not found", id.0))
190    }
191
192    pub fn get_session(&self, id: &AgentId) -> Option<&AgentSession> {
193        self.agents.get(id)
194    }
195
196    pub fn get_session_mut(&mut self, id: &AgentId) -> Option<&mut AgentSession> {
197        self.agents.get_mut(id)
198    }
199
200    // ── Navigation ─────────────────────────────────────────────────────────
201
202    pub fn navigate(&mut self, agent: &AgentId, direction: &str) -> Result<RoomId, String> {
203        let session = self
204            .agents
205            .get(agent)
206            .ok_or_else(|| format!("Agent {} not found", agent.0))?;
207        let current_room_id = session.current_room.clone();
208
209        let exit = {
210            let room = self
211                .rooms
212                .get(&current_room_id)
213                .ok_or_else(|| format!("Room {} not found", current_room_id.0))?;
214            room.exits
215                .iter()
216                .find(|e| e.direction == direction)
217                .cloned()
218                .ok_or_else(|| format!("No exit '{}' from {}", direction, room.name))?
219        };
220
221        if exit.locked {
222            return Err(format!("Exit '{}' is locked", direction));
223        }
224
225        // CONSTRAINT 5: Room exits must preserve mathematical guarantees
226        if !self
227            .alignment
228            .check_exit_constraint(&current_room_id, &exit.target)
229        {
230            return Err(
231                "ALIGNMENT VIOLATION: Exit violates mathematical guarantees (Constraint 5)".into(),
232            );
233        }
234
235        let target_id = exit.target.clone();
236        if let Some(session) = self.agents.get_mut(agent) {
237            session.current_room = target_id.clone();
238        }
239        Ok(target_id)
240    }
241
242    // ── Inventory ──────────────────────────────────────────────────────────
243
244    pub fn pick_up_tile(&mut self, agent: &AgentId, tile_id: &TileId) -> Result<(), String> {
245        let session = self
246            .agents
247            .get(agent)
248            .ok_or_else(|| format!("Agent {} not found", agent.0))?;
249        let room_id = session.current_room.clone();
250
251        // Check tile is in the room
252        {
253            let room = self
254                .rooms
255                .get(&room_id)
256                .ok_or_else(|| format!("Room {} not found", room_id.0))?;
257            if !room.tiles.contains(tile_id) {
258                return Err(format!("Tile {} not in room", tile_id.0));
259            }
260        }
261
262        // Remove from room, add to inventory
263        if let Some(room) = self.rooms.get_mut(&room_id) {
264            room.tiles.retain(|t| t != tile_id);
265        }
266        if let Some(session) = self.agents.get_mut(agent) {
267            session.inventory.push(tile_id.clone());
268        }
269        Ok(())
270    }
271
272    pub fn drop_tile(&mut self, agent: &AgentId, tile_id: &TileId) -> Result<(), String> {
273        let session = self
274            .agents
275            .get(agent)
276            .ok_or_else(|| format!("Agent {} not found", agent.0))?;
277        let room_id = session.current_room.clone();
278
279        // Check tile is in inventory
280        {
281            let session = self.agents.get(agent).unwrap();
282            if !session.inventory.contains(tile_id) {
283                return Err(format!("Tile {} not in inventory", tile_id.0));
284            }
285        }
286
287        // Remove from inventory, add to room
288        if let Some(session) = self.agents.get_mut(agent) {
289            session.inventory.retain(|t| t != tile_id);
290        }
291        if let Some(room) = self.rooms.get_mut(&room_id) {
292            room.tiles.push(tile_id.clone());
293        }
294        Ok(())
295    }
296
297    // ── Crafting ───────────────────────────────────────────────────────────
298
299    pub fn craft(
300        &mut self,
301        agent: &AgentId,
302        input_ids: &[TileId],
303        recipe_name: &str,
304    ) -> Result<Tile, String> {
305        let session = self
306            .agents
307            .get(agent)
308            .ok_or_else(|| format!("Agent {} not found", agent.0))?;
309        let room_id = session.current_room.clone();
310
311        // Check room has workbench
312        let recipe = {
313            let room = self
314                .rooms
315                .get(&room_id)
316                .ok_or_else(|| format!("Room {} not found", room_id.0))?;
317            let wb = room.workbench.as_ref().ok_or("No workbench in this room")?;
318            wb.recipes
319                .iter()
320                .find(|r| r.name == recipe_name)
321                .cloned()
322                .ok_or_else(|| format!("Recipe '{}' not found", recipe_name))?
323        };
324
325        // CONSTRAINT 3: Cite dependencies
326        for input_id in input_ids {
327            if !self.tiles.contains_key(input_id) {
328                return Err(format!(
329                    "ALIGNMENT VIOLATION: Input tile {} not found (Constraint 3)",
330                    input_id.0
331                ));
332            }
333        }
334
335        // Verify inputs match recipe
336        if input_ids.len() != recipe.inputs.len() {
337            return Err("Input count doesn't match recipe".into());
338        }
339
340        // Check inputs are in inventory or room
341        let session = self.agents.get(agent).unwrap();
342        for input_id in input_ids {
343            let has_it = session.inventory.contains(input_id) || {
344                self.rooms
345                    .get(&room_id)
346                    .map(|r| r.tiles.contains(input_id))
347                    .unwrap_or(false)
348            };
349            if !has_it {
350                return Err(format!("Input tile {} not available", input_id.0));
351            }
352        }
353
354        // Produce output tile
355        let agent_id = session.agent_id.clone();
356        let output_tile = Tile {
357            id: TileId(format!("{}:{}", recipe_name, self.tiles.len())),
358            title: recipe.name.clone(),
359            location: SpatialIndex {
360                x: 0.0,
361                y: 0.0,
362                z: 0.0,
363            },
364            author: agent_id,
365            confidence: 0.5,
366            domain_tags: vec![],
367            links: input_ids.to_vec(),
368            content: recipe.output.clone(),
369            lifecycle: Lifecycle::Created,
370            bloom_hash: [0u8; 32],
371        };
372
373        let tile = output_tile.clone();
374        self.add_tile(output_tile)?;
375        Ok(tile)
376    }
377
378    // ── NPC Interaction ────────────────────────────────────────────────────
379
380    pub fn talk_to_npc(
381        &mut self,
382        _agent: &AgentId,
383        npc_id: &NpcId,
384        query: &str,
385    ) -> Result<String, String> {
386        let npc = self
387            .npcs
388            .get(npc_id)
389            .ok_or_else(|| format!("NPC {} not found", npc_id.0))?;
390
391        // CONSTRAINT 4: NPC must not give advice outside its expertise
392        if !npc.expertise.is_empty() {
393            let query_lower = query.to_lowercase();
394            let relevant = npc
395                .expertise
396                .iter()
397                .any(|e| query_lower.contains(&e.to_lowercase()));
398            if !relevant {
399                return Err(format!(
400                    "ALIGNMENT VIOLATION: {} cannot advise on '{}' — outside expertise (Constraint 4)",
401                    npc.name, query
402                ));
403            }
404        }
405
406        let query_key = Query(query.to_string());
407        let response = npc
408            .knowledge_graph
409            .get(&query_key)
410            .map(|r| r.0.clone())
411            .unwrap_or_else(|| {
412                format!(
413                    "{} scratches their head. \"I don't know about that.\"",
414                    npc.name
415                )
416            });
417
418        Ok(response)
419    }
420
421    // ── Look ───────────────────────────────────────────────────────────────
422
423    pub fn look(&self, agent: &AgentId) -> Result<String, String> {
424        let session = self
425            .agents
426            .get(agent)
427            .ok_or_else(|| format!("Agent {} not found", agent.0))?;
428        let room = self
429            .rooms
430            .get(&session.current_room)
431            .ok_or_else(|| format!("Room {} not found", session.current_room.0))?;
432
433        let mut desc = format!("═══ {} ═══\n", room.name);
434        desc.push_str(&format!("{}\n", room.description));
435        desc.push_str(&format!(
436            "Domain: {} | Depth: {:?} | State: {:?}\n",
437            room.domain.name(),
438            room.depth,
439            room.state
440        ));
441
442        if !room.exits.is_empty() {
443            desc.push_str("\nExits: ");
444            let exits: Vec<String> = room
445                .exits
446                .iter()
447                .filter(|e| !e.locked)
448                .map(|e| format!("{} [{}]", e.direction, e.target.0))
449                .collect();
450            desc.push_str(&exits.join(", "));
451            desc.push('\n');
452        }
453
454        if !room.tiles.is_empty() {
455            desc.push_str(&format!("\nTiles here ({}):\n", room.tiles.len()));
456            for tid in &room.tiles {
457                if let Some(tile) = self.tiles.get(tid) {
458                    desc.push_str(&format!(
459                        "  📦 {} (confidence: {:.2})\n",
460                        tile.title, tile.confidence
461                    ));
462                }
463            }
464        }
465
466        if !room.npcs.is_empty() {
467            desc.push_str(&format!("\nNPCs here ({}):\n", room.npcs.len()));
468            for nid in &room.npcs {
469                if let Some(npc) = self.npcs.get(nid) {
470                    desc.push_str(&format!("  🧑 {} — {}\n", npc.name, npc.personality));
471                }
472            }
473        }
474
475        if let Some(ref wb) = room.workbench {
476            desc.push_str(&format!("\n⚒️ Workbench: {}\n", wb.name));
477            desc.push_str(&format!("   {}\n", wb.description));
478            for recipe in &wb.recipes {
479                desc.push_str(&format!(
480                    "   Recipe: {} — {}\n",
481                    recipe.name, recipe.description
482                ));
483            }
484        }
485
486        Ok(desc)
487    }
488
489    // ── Map ────────────────────────────────────────────────────────────────
490
491    pub fn map(&self, agent: &AgentId) -> Result<String, String> {
492        let session = self
493            .agents
494            .get(agent)
495            .ok_or_else(|| format!("Agent {} not found", agent.0))?;
496
497        let mut map_str = String::from("╔═══════════════════════════════════╗\n");
498        map_str.push_str("║         PLATO MUD MAP            ║\n");
499        map_str.push_str("╠═══════════════════════════════════╣\n");
500
501        for room in self.rooms.values() {
502            let marker = if room.id == session.current_room {
503                " ◄ YOU"
504            } else {
505                ""
506            };
507            map_str.push_str(&format!(
508                "║ [{}] {} ({:?}){}{}\n",
509                room.domain.name(),
510                room.name,
511                room.depth,
512                if room.state != RoomState::Dormant {
513                    format!(" [{:?}]", room.state)
514                } else {
515                    String::new()
516                },
517                marker
518            ));
519            for exit in &room.exits {
520                map_str.push_str(&format!("║   → {} → {}\n", exit.direction, exit.target.0));
521            }
522        }
523        map_str.push_str("╚═══════════════════════════════════╝\n");
524        Ok(map_str)
525    }
526
527    // ── Zeitgeist Access ───────────────────────────────────────────────────
528
529    pub fn zeitgeist(&self) -> &Zeitgeist {
530        &self.zeitgeist
531    }
532
533    pub fn zeitgeist_mut(&mut self) -> &mut Zeitgeist {
534        &mut self.zeitgeist
535    }
536
537    // ── Command Dispatcher ─────────────────────────────────────────────────
538
539    pub fn execute(&mut self, agent: &AgentId, cmd: Command) -> Result<String, String> {
540        // All commands pass through alignment checking
541        self.alignment.check_command(agent, &cmd, self)?;
542
543        match cmd {
544            Command::Look => self.look(agent),
545            Command::Go(dir) => {
546                let _new_room = self.navigate(agent, &dir)?;
547                self.look(agent)
548            }
549            Command::Get(item) => {
550                self.pick_up_tile(agent, &TileId(item.clone()))?;
551                Ok(format!("Picked up {}", item))
552            }
553            Command::Drop(item) => {
554                self.drop_tile(agent, &TileId(item.clone()))?;
555                Ok(format!("Dropped {}", item))
556            }
557            Command::Talk(npc_name) => {
558                // Find NPC by name in current room
559                let session = self
560                    .agents
561                    .get(agent)
562                    .cloned()
563                    .ok_or_else(|| format!("Agent {} not found", agent.0))?;
564                let npc_id = self
565                    .npcs
566                    .values()
567                    .find(|n| n.name == npc_name && n.room == session.current_room)
568                    .map(|n| n.id.clone())
569                    .ok_or_else(|| format!("NPC '{}' not in this room", npc_name))?;
570                self.talk_to_npc(agent, &npc_id, "hello")
571            }
572            Command::Craft(items) => {
573                let tile_ids: Vec<TileId> = items.iter().map(|s| TileId(s.clone())).collect();
574                let tile = self.craft(agent, &tile_ids, "combine")?;
575                Ok(format!("Crafted: {}", tile.title))
576            }
577            Command::Inventory => {
578                let session = self
579                    .agents
580                    .get(agent)
581                    .ok_or_else(|| format!("Agent {} not found", agent.0))?;
582                if session.inventory.is_empty() {
583                    Ok("Inventory is empty.".into())
584                } else {
585                    let mut inv = format!("Inventory ({}):\n", session.inventory.len());
586                    for tid in &session.inventory {
587                        if let Some(tile) = self.tiles.get(tid) {
588                            inv.push_str(&format!(
589                                "  📦 {} ({:.2})\n",
590                                tile.title, tile.confidence
591                            ));
592                        }
593                    }
594                    Ok(inv)
595                }
596            }
597            Command::Map => self.map(agent),
598            Command::Examine(target) => {
599                let tile = self.get_tile(&TileId(target.clone()));
600                if let Some(tile) = tile {
601                    Ok(format!(
602                        "📦 {}\nConfidence: {:.2}\nLifecycle: {:?}\nTags: {}\nAuthor: {}",
603                        tile.title,
604                        tile.confidence,
605                        tile.lifecycle,
606                        tile.domain_tags.join(", "),
607                        tile.author.0
608                    ))
609                } else {
610                    Err(format!("'{}' not found", target))
611                }
612            }
613            Command::Status => {
614                let session = self
615                    .agents
616                    .get(agent)
617                    .ok_or_else(|| format!("Agent {} not found", agent.0))?;
618                let room = self.rooms.get(&session.current_room);
619                Ok(format!(
620                    "Agent: {} | Room: {} | Inventory: {} | Zeitgeist: beat {}",
621                    agent.0,
622                    room.map(|r| r.name.clone()).unwrap_or_default(),
623                    session.inventory.len(),
624                    self.zeitgeist.temporal.beat
625                ))
626            }
627            Command::Help => Ok(
628                "Commands: LOOK, GO <dir>, GET <tile>, DROP <tile>, TALK <npc>, \
629                    CRAFT <tiles...>, INVENTORY, MAP, EXAMINE <tile>, STATUS, HELP"
630                    .into(),
631            ),
632        }
633    }
634
635    /// Process an incoming FLUX transference
636    pub fn receive_flux(&mut self, flux: &FluxTransference) -> Result<(), String> {
637        // CONSTRAINT 8: FLUX must carry full zeitgeist
638        if flux.timestamp <= 0.0 {
639            return Err("ALIGNMENT VIOLATION: FLUX missing zeitgeist (Constraint 8)".into());
640        }
641
642        // CONSTRAINT 6: Zeitgeist merge must be CRDT (commutative, associative, idempotent)
643        self.zeitgeist.merge(&flux.zeitgeist);
644
645        // Process payload
646        match &flux.payload {
647            TransferencePayload::Tile(tile) => {
648                self.add_tile(tile.clone())?;
649            }
650            TransferencePayload::StateUpdate(state) => {
651                if let Some(room) = self.rooms.get_mut(&flux.target) {
652                    room.state = state.clone();
653                }
654            }
655            TransferencePayload::AlignmentCheck(_report) => {
656                // Log alignment report
657            }
658            TransferencePayload::Knowledge(_knowledge) => {}
659            TransferencePayload::Heartbeat => {}
660        }
661
662        Ok(())
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    fn make_test_room(id: &str, name: &str, domain: Domain) -> Room {
671        Room {
672            id: RoomId(id.to_string()),
673            name: name.to_string(),
674            description: format!("You are in the {} room.", name),
675            domain,
676            exits: vec![],
677            tiles: vec![],
678            npcs: vec![],
679            workbench: None,
680            depth: Depth::Introductory,
681            state: RoomState::Dormant,
682        }
683    }
684
685    fn make_test_tile(id: &str, confidence: f64) -> Tile {
686        Tile {
687            id: TileId(id.to_string()),
688            title: format!("Test tile {}", id),
689            location: SpatialIndex {
690                x: 0.0,
691                y: 0.0,
692                z: 0.0,
693            },
694            author: AgentId("test-agent".to_string()),
695            confidence,
696            domain_tags: vec!["test".to_string()],
697            links: vec![],
698            content: TileContent::Code("fn test() {}".to_string()),
699            lifecycle: Lifecycle::Created,
700            bloom_hash: [0u8; 32],
701        }
702    }
703
704    #[test]
705    fn test_add_room() {
706        let mut engine = Engine::new();
707        let room = make_test_room("rust-01", "Rust Basics", Domain::Rust);
708        assert!(engine.add_room(room).is_ok());
709        assert!(engine.get_room(&RoomId("rust-01".to_string())).is_some());
710    }
711
712    #[test]
713    fn test_duplicate_room() {
714        let mut engine = Engine::new();
715        let room = make_test_room("rust-01", "Rust Basics", Domain::Rust);
716        engine.add_room(room.clone()).unwrap();
717        assert!(engine.add_room(room).is_err());
718    }
719
720    #[test]
721    fn test_add_tile_confidence_constraint() {
722        let mut engine = Engine::new();
723        // Confidence > 0.95 without empirical evidence should fail
724        let tile = make_test_tile("t1", 0.99);
725        assert!(engine.add_tile(tile).is_err());
726
727        // With empirical data, it should succeed
728        let mut tile2 = make_test_tile("t2", 0.99);
729        tile2.content = TileContent::EmpiricalData("benchmarked".to_string());
730        assert!(engine.add_tile(tile2).is_ok());
731    }
732
733    #[test]
734    fn test_add_tile_normal_confidence() {
735        let mut engine = Engine::new();
736        let tile = make_test_tile("t1", 0.85);
737        assert!(engine.add_tile(tile).is_ok());
738    }
739
740    #[test]
741    fn test_navigation() {
742        let mut engine = Engine::new();
743        let mut room1 = make_test_room("rust-01", "Rust Basics", Domain::Rust);
744        let room2 = make_test_room("rust-02", "Rust Advanced", Domain::Rust);
745        room1.exits.push(Exit {
746            direction: "north".to_string(),
747            target: room2.id.clone(),
748            description: "A path to advanced Rust".to_string(),
749            locked: false,
750        });
751        engine.add_room(room1).unwrap();
752        engine.add_room(room2).unwrap();
753
754        let agent = AgentId("test".to_string());
755        engine
756            .connect_agent(agent.clone(), RoomId("rust-01".to_string()))
757            .unwrap();
758
759        let result = engine.navigate(&agent, "north");
760        assert!(result.is_ok());
761        assert_eq!(result.unwrap(), RoomId("rust-02".to_string()));
762    }
763
764    #[test]
765    fn test_navigation_locked_exit() {
766        let mut engine = Engine::new();
767        let mut room1 = make_test_room("r1", "Room 1", Domain::Concept);
768        let room2 = make_test_room("r2", "Room 2", Domain::Concept);
769        room1.exits.push(Exit {
770            direction: "east".to_string(),
771            target: room2.id.clone(),
772            description: "Locked".to_string(),
773            locked: true,
774        });
775        engine.add_room(room1).unwrap();
776        engine.add_room(room2).unwrap();
777
778        let agent = AgentId("test".to_string());
779        engine
780            .connect_agent(agent.clone(), RoomId("r1".to_string()))
781            .unwrap();
782        assert!(engine.navigate(&agent, "east").is_err());
783    }
784
785    #[test]
786    fn test_inventory() {
787        let mut engine = Engine::new();
788        let room = make_test_room("r1", "Test Room", Domain::Rust);
789        engine.add_room(room).unwrap();
790
791        let tile = make_test_tile("t1", 0.5);
792        engine.add_tile(tile.clone()).unwrap();
793
794        // Add tile to room
795        engine
796            .rooms
797            .get_mut(&RoomId("r1".to_string()))
798            .unwrap()
799            .tiles
800            .push(tile.id.clone());
801
802        let agent = AgentId("test".to_string());
803        engine
804            .connect_agent(agent.clone(), RoomId("r1".to_string()))
805            .unwrap();
806
807        assert!(engine
808            .pick_up_tile(&agent, &TileId("t1".to_string()))
809            .is_ok());
810        let session = engine.get_session(&agent).unwrap();
811        assert!(session.inventory.contains(&TileId("t1".to_string())));
812
813        assert!(engine.drop_tile(&agent, &TileId("t1".to_string())).is_ok());
814        let session = engine.get_session(&agent).unwrap();
815        assert!(session.inventory.is_empty());
816    }
817
818    #[test]
819    fn test_npc_talk() {
820        let mut engine = Engine::new();
821        engine
822            .add_room(make_test_room("r1", "Test", Domain::Rust))
823            .unwrap();
824
825        let mut knowledge = BTreeMap::new();
826        knowledge.insert(
827            Query("borrowing".to_string()),
828            Response("Ownership is key in Rust!".to_string()),
829        );
830
831        let npc = Npc {
832            id: NpcId("rusty".to_string()),
833            name: "Rusty".to_string(),
834            room: RoomId("r1".to_string()),
835            expertise: vec!["rust".to_string(), "borrowing".to_string()],
836            personality: "Gruff but knowledgeable".to_string(),
837            knowledge_graph: knowledge,
838            current_dialog: None,
839        };
840        engine.add_npc(npc).unwrap();
841
842        let agent = AgentId("test".to_string());
843        engine
844            .connect_agent(agent.clone(), RoomId("r1".to_string()))
845            .unwrap();
846
847        let response = engine.talk_to_npc(&agent, &NpcId("rusty".to_string()), "borrowing");
848        assert!(response.is_ok());
849        assert!(response.unwrap().contains("Ownership"));
850    }
851
852    #[test]
853    fn test_npc_constraint_4() {
854        let mut engine = Engine::new();
855        engine
856            .add_room(make_test_room("r1", "Test", Domain::Rust))
857            .unwrap();
858
859        let npc = Npc {
860            id: NpcId("rusty".to_string()),
861            name: "Rusty".to_string(),
862            room: RoomId("r1".to_string()),
863            expertise: vec!["rust".to_string()],
864            personality: "Rust only".to_string(),
865            knowledge_graph: BTreeMap::new(),
866            current_dialog: None,
867        };
868        engine.add_npc(npc).unwrap();
869
870        let agent = AgentId("test".to_string());
871        engine
872            .connect_agent(agent.clone(), RoomId("r1".to_string()))
873            .unwrap();
874
875        // Asking about python should fail (outside expertise)
876        let response = engine.talk_to_npc(&agent, &NpcId("rusty".to_string()), "python gil");
877        assert!(response.is_err());
878        assert!(response.unwrap_err().contains("Constraint 4"));
879    }
880
881    #[test]
882    fn test_command_dispatch() {
883        let mut engine = Engine::new();
884        engine
885            .add_room(make_test_room("r1", "Test Room", Domain::Concept))
886            .unwrap();
887
888        let agent = AgentId("test".to_string());
889        engine
890            .connect_agent(agent.clone(), RoomId("r1".to_string()))
891            .unwrap();
892
893        let result = engine.execute(&agent, Command::Look);
894        assert!(result.is_ok());
895        assert!(result.unwrap().contains("Test Room"));
896
897        let result = engine.execute(&agent, Command::Help);
898        assert!(result.is_ok());
899    }
900
901    #[test]
902    fn test_rooms_by_domain() {
903        let mut engine = Engine::new();
904        engine
905            .add_room(make_test_room("r1", "Rust 1", Domain::Rust))
906            .unwrap();
907        engine
908            .add_room(make_test_room("r2", "Rust 2", Domain::Rust))
909            .unwrap();
910        engine
911            .add_room(make_test_room("c1", "C Basics", Domain::C))
912            .unwrap();
913
914        assert_eq!(engine.rooms_by_domain(&Domain::Rust).len(), 2);
915        assert_eq!(engine.rooms_by_domain(&Domain::C).len(), 1);
916    }
917
918    #[test]
919    fn test_zeitgeist_merge() {
920        let mut z1 = Zeitgeist::new();
921        let mut z2 = Zeitgeist::new();
922        z2.precision.width = 0.1;
923        z2.precision.samples = 100;
924        z2.trajectory.confidence = 0.9;
925        z2.temporal.beat = 42;
926
927        z1.merge(&z2);
928        assert_eq!(z1.precision.width, 0.1);
929        assert_eq!(z1.precision.samples, 100);
930        assert_eq!(z1.trajectory.confidence, 0.9);
931        assert_eq!(z1.temporal.beat, 42);
932
933        // Idempotent
934        z1.merge(&z2);
935        assert_eq!(z1.precision.width, 0.1);
936        assert_eq!(z1.precision.samples, 200); // samples accumulate
937        assert_eq!(z1.temporal.beat, 42); // max stays same
938
939        // Commutative
940        let mut z3 = Zeitgeist::new();
941        z3.precision.width = 0.3;
942        z3.precision.samples = 50;
943        let z1_before = z1.clone();
944        z3.merge(&z1);
945        assert_eq!(z3.precision.width, 0.1); // narrower wins
946    }
947
948    #[test]
949    fn test_receive_flux() {
950        let mut engine = Engine::new();
951        engine
952            .add_room(make_test_room("r1", "Target", Domain::Rust))
953            .unwrap();
954
955        let tile = make_test_tile("flux-tile", 0.5);
956        let flux = FluxTransference {
957            source: RoomId("remote".to_string()),
958            target: RoomId("r1".to_string()),
959            timestamp: 1234.0,
960            payload: TransferencePayload::Tile(tile),
961            zeitgeist: Zeitgeist::new(),
962        };
963
964        assert!(engine.receive_flux(&flux).is_ok());
965        assert!(engine.get_tile(&TileId("flux-tile".to_string())).is_some());
966    }
967
968    #[test]
969    fn test_flux_missing_zeitgeist() {
970        let mut engine = Engine::new();
971        let flux = FluxTransference {
972            source: RoomId("a".to_string()),
973            target: RoomId("b".to_string()),
974            timestamp: 0.0, // invalid
975            payload: TransferencePayload::Heartbeat,
976            zeitgeist: Zeitgeist::new(),
977        };
978        assert!(engine.receive_flux(&flux).is_err());
979    }
980
981    #[test]
982    fn test_look_output() {
983        let mut engine = Engine::new();
984        let mut room = make_test_room("r1", "Rust Shrine", Domain::Rust);
985        room.description = "Candlelit corridors of unsafe code.".to_string();
986        room.exits.push(Exit {
987            direction: "north".to_string(),
988            target: RoomId("r2".to_string()),
989            description: "Deeper".to_string(),
990            locked: false,
991        });
992        engine.add_room(room).unwrap();
993
994        let tile = make_test_tile("t1", 0.75);
995        engine.add_tile(tile.clone()).unwrap();
996        engine
997            .rooms
998            .get_mut(&RoomId("r1".to_string()))
999            .unwrap()
1000            .tiles
1001            .push(tile.id);
1002
1003        let agent = AgentId("wanderer".to_string());
1004        engine
1005            .connect_agent(agent.clone(), RoomId("r1".to_string()))
1006            .unwrap();
1007
1008        let output = engine.execute(&agent, Command::Look).unwrap();
1009        assert!(output.contains("Rust Shrine"));
1010        assert!(output.contains("Candlelit"));
1011        assert!(output.contains("north"));
1012        assert!(output.contains("Test tile t1"));
1013    }
1014}