// Text renderer for ObserverSnapshot — structured human-readable output. // Library code callable by test-client crate and server integration tests. // The server binary never references this module. use std::fmt::Write; use crate::bridge::types::*; use crate::knowledge::types::{EntityVisibility, RelationshipState}; /// Format an ObserverSnapshot as structured text for human verification. /// /// Output format matches the test-client --text specification: /// header, game time, room, entities (sorted by distance), pending /// recognitions, tiles, interactions, inventory, monologue, dialogue. pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String { let mut out = String::with_capacity(2048); // Find player entity for position reference let player = snapshot .entities .iter() .find(|e| matches!(e.kind, EntityKind::Player)); let (px, py) = player.map(|p| (p.x as i32, p.y as i32)).unwrap_or((-1, -1)); // Header writeln!( out, "=== Tick {} | Player ({},{}) facing {:?} | Stance: {:?} | TickRate: {:?} ===", snapshot.tick, px, py, snapshot.player_facing, snapshot.player_stance, snapshot.game_time.tick_rate, ) .ok(); // Game time let hours = (snapshot.game_time.time_of_day / 60) % 24; let minutes = snapshot.game_time.time_of_day % 60; writeln!( out, "Game time: Day {}, {:02}:{:02} ({:?})", snapshot.game_time.day, hours, minutes, snapshot.game_time.day_phase, ) .ok(); // Room name — stub until Gauntlet room constants are implemented (Sprint 9) writeln!(out, "Room: (unknown)").ok(); // Non-player entities sorted by distance then entity_id let mut entities: Vec<&VisibleEntity> = snapshot .entities .iter() .filter(|e| !matches!(e.kind, EntityKind::Player)) .collect(); entities.sort_by_key(|e| { let dist = (e.x as i32 - px).unsigned_abs() + (e.y as i32 - py).unsigned_abs(); (dist, e.entity_id) }); if !entities.is_empty() { writeln!(out, "Entities ({}):", entities.len()).ok(); for e in &entities { let dist = (e.x as i32 - px).unsigned_abs() + (e.y as i32 - py).unsigned_abs(); writeln!( out, " {}:{:<8} ({},{}) {:<8} rel:{:<16} vis:{:<12} d={}", kind_label(e.kind), e.entity_id, e.x as i32, e.y as i32, sector_label(e.visibility), relationship_label(e.relationship), observation_label(&e.observation), dist, ) .ok(); } } // Pending recognitions if !snapshot.pending_recognitions.is_empty() { write!( out, "Pending recognitions: {}", snapshot.pending_recognitions.len() ) .ok(); for pr in &snapshot.pending_recognitions { let elapsed = pr.total_delay_ticks - pr.remaining_ticks; write!( out, " [npc:{} at ({},{}) {}/{} ticks]", pr.entity_id, pr.x as i32, pr.y as i32, elapsed, pr.total_delay_ticks, ) .ok(); } writeln!(out).ok(); } // Tiles writeln!(out, "Tiles: {} visible", snapshot.visible_tiles.len()).ok(); // Interactions if !snapshot.nearby_interactions.is_empty() { writeln!( out, "Interactions ({}):", snapshot.nearby_interactions.len() ) .ok(); for ni in &snapshot.nearby_interactions { let verbs: Vec = ni .verbs .iter() .map(|v| format!("{}({})", v.label, v.priority)) .collect(); writeln!( out, " {}:{} [{}] distance={}", kind_label(ni.entity_type), ni.entity_id, verbs.join(", "), ni.distance, ) .ok(); } } // Inventory if !snapshot.player_inventory.is_empty() { let slots: Vec = snapshot .player_inventory .iter() .map(|item| format!("item:{}(slot-{})", item.item_id, item.slot)) .collect(); writeln!( out, "Inventory: {}/9 [{}]", snapshot.player_inventory.len(), slots.join(", "), ) .ok(); } // Monologue if let Some(ref mono) = snapshot.current_monologue { writeln!(out, "Monologue: \"{}\"", mono.text).ok(); } // Dialogue response if let Some(ref dialogue) = snapshot.dialogue_response { writeln!( out, "Dialogue: [npc:{}] \"{}\"", dialogue.speaker_entity_id, dialogue.text ) .ok(); } // Blocked entities (debug, #514) if !snapshot.blocked_entities.is_empty() { let ids: Vec = snapshot .blocked_entities .iter() .map(|id| id.to_string()) .collect(); writeln!( out, "Blocked (LOS): {} [{}]", snapshot.blocked_entities.len(), ids.join(", ") ) .ok(); } writeln!(out, "===").ok(); out } fn kind_label(kind: EntityKind) -> &'static str { match kind { EntityKind::Player => "player", EntityKind::Npc => "npc", EntityKind::Object => "obj", EntityKind::Terrain => "terrain", } } fn sector_label(sector: VisibilitySector) -> &'static str { match sector { VisibilitySector::Forward => "Forward", VisibilitySector::Peripheral => "Periph", } } fn relationship_label(rel: RelationshipState) -> &'static str { match rel { RelationshipState::Unknown => "Unknown", RelationshipState::Known => "Known", RelationshipState::Friendly => "Friendly", RelationshipState::PersonOfInterest => "POI", RelationshipState::Hostile => "Hostile", } } fn observation_label(obs: &EntityVisibility) -> &'static str { match obs { EntityVisibility::Visible => "Visible", EntityVisibility::Remembered { .. } => "Remembered", } } #[cfg(test)] mod tests { use super::*; use crate::simulation::time::{DayPhase, TickRate}; fn make_snapshot() -> ObserverSnapshot { ObserverSnapshot { version: PROTOCOL_VERSION, tick: 42, game_time: GameTime { day: 0, time_of_day: 252, day_phase: DayPhase::Morning, tick_rate: TickRate::Full, }, player_facing: FacingDirection::East, player_stance: MovementStance::Walk, player_inventory: vec![], entities: vec![ VisibleEntity { entity_id: 1, x: 15.0, y: 10.0, z: 0, kind: EntityKind::Player, visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, }, VisibleEntity { entity_id: 100, x: 18.0, y: 10.0, z: 0, kind: EntityKind::Npc, visibility: VisibilitySector::Forward, relationship: RelationshipState::Known, observation: EntityVisibility::Visible, }, VisibleEntity { entity_id: 200, x: 16.0, y: 9.0, z: 0, kind: EntityKind::Object, visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, }, ], visible_tiles: vec![VisibleTile { x: 15, y: 10, z: 0, visibility: VisibilitySector::Forward, tile_kind: TileKind::Floor, zone_id: None, }], nearby_interactions: vec![NearbyInteraction { entity_id: 100, entity_type: EntityKind::Npc, distance: 3, verbs: vec![ VerbOption { kind: VerbKind::Talk, label: "Talk".into(), priority: 1, available: true, }, VerbOption { kind: VerbKind::ExamineNpc, label: "ExamineNpc".into(), priority: 2, available: true, }, ], object_type: None, contradicted: false, }], current_monologue: Some(MonologueEvent { id: "mono_test_1".into(), text: "Something about this manifest doesn't add up.".into(), duration_seconds: 3.0, }), pending_recognitions: vec![], dialogue_response: None, blocked_entities: vec![], scan_events: vec![], conversation_events: vec![], conversation_ended: vec![], sound_events: vec![], rng_seed: None, } } #[test] fn header_contains_tick_and_position() { let text = format_snapshot_text(&make_snapshot()); assert!(text.contains("Tick 42")); assert!(text.contains("Player (15,10)")); assert!(text.contains("facing East")); assert!(text.contains("Stance: Walk")); } #[test] fn game_time_formatted() { let text = format_snapshot_text(&make_snapshot()); assert!(text.contains("Day 0, 04:12 (Morning)")); } #[test] fn entities_sorted_by_distance() { let text = format_snapshot_text(&make_snapshot()); // obj:200 at (16,9) is distance 2 from player (15,10) // npc:100 at (18,10) is distance 3 from player (15,10) let obj_pos = text.find("obj:200").unwrap(); let npc_pos = text.find("npc:100").unwrap(); assert!( obj_pos < npc_pos, "obj:200 (d=2) should appear before npc:100 (d=3)" ); } #[test] fn player_excluded_from_entity_list() { let text = format_snapshot_text(&make_snapshot()); assert!(text.contains("Entities (2):")); assert!(!text.contains("player:1")); } #[test] fn interactions_rendered() { let text = format_snapshot_text(&make_snapshot()); assert!(text.contains("Interactions (1):")); assert!(text.contains("Talk(1)")); assert!(text.contains("distance=3")); } #[test] fn monologue_rendered() { let text = format_snapshot_text(&make_snapshot()); assert!(text.contains("Monologue: \"Something about this manifest doesn't add up.\"")); } #[test] fn dialogue_rendered_when_present() { let mut snap = make_snapshot(); snap.dialogue_response = Some(DialogueResponseEvent { line_id: "line_test".into(), text: "Welcome to the docks.".into(), speaker_entity_id: 100, }); let text = format_snapshot_text(&snap); assert!(text.contains("Dialogue: [npc:100] \"Welcome to the docks.\"")); } #[test] fn pending_recognitions_rendered() { let mut snap = make_snapshot(); snap.pending_recognitions = vec![PendingRecognitionWire { entity_id: 104, x: 19.0, y: 12.0, z: 0, remaining_ticks: 5, total_delay_ticks: 8, }]; let text = format_snapshot_text(&snap); assert!(text.contains("Pending recognitions: 1")); assert!(text.contains("npc:104 at (19,12) 3/8 ticks")); } #[test] fn inventory_rendered() { let mut snap = make_snapshot(); snap.player_inventory = vec![ InventoryItem { item_id: 300, name: "Manifest".into(), slot: 0, }, InventoryItem { item_id: 301, name: "Keycard".into(), slot: 3, }, ]; let text = format_snapshot_text(&snap); assert!(text.contains("Inventory: 2/9")); assert!(text.contains("item:300(slot-0)")); assert!(text.contains("item:301(slot-3)")); } #[test] fn empty_snapshot_no_panic() { let snap = ObserverSnapshot { version: PROTOCOL_VERSION, tick: 0, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, player_stance: MovementStance::Walk, player_inventory: vec![], entities: vec![], visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, pending_recognitions: vec![], dialogue_response: None, blocked_entities: vec![], scan_events: vec![], conversation_events: vec![], conversation_ended: vec![], sound_events: vec![], rng_seed: None, }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); assert!(text.contains("Player (-1,-1)")); assert!(text.contains("Tiles: 0 visible")); } #[test] fn blocked_entities_rendered() { let mut snap = make_snapshot(); snap.blocked_entities = vec![42, 99, 1024]; let text = format_snapshot_text(&snap); assert!(text.contains("Blocked (LOS): 3")); assert!(text.contains("[42, 99, 1024]")); } #[test] fn blocked_entities_empty_not_rendered() { let snap = make_snapshot(); let text = format_snapshot_text(&snap); assert!(!text.contains("Blocked (LOS)")); } }