diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 5c5d93478..21d8aa8a5 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -9,6 +9,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs; pub mod framing; pub mod local; pub mod tcp; +pub mod text_renderer; pub mod types; pub use types::*; diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs new file mode 100644 index 000000000..d80d4bcd5 --- /dev/null +++ b/server/src/bridge/text_renderer.rs @@ -0,0 +1,411 @@ +// 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(); + } + + 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, + }], + 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, + } + } + + #[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, + }; + let text = format_snapshot_text(&snap); + assert!(text.contains("Tick 0")); + assert!(text.contains("Player (-1,-1)")); + assert!(text.contains("Tiles: 0 visible")); + } +}