feat(simulation): add snapshot text renderer library (#481)
format_snapshot_text(&ObserverSnapshot) -> String pub-exported from server crate. Entity labels as kind:entity_id sorted by distance, room name stubbed as (unknown) until Gauntlet constants land. 10 unit tests. Consumed by tooling/test-client for --text mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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<String> = 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<String> = 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user