Files
settled-reach/server/src/bridge/text_renderer.rs
T
jpmschweitzerandClaude Opus 4.6 c436ae905f feat(simulation): wire triangle escalation and crisis events into protocol
Registers tick_triangle_escalation and apply_resolve_triangle systems
in SimulationPlugin. Adds TriangleCrisisEventWire to ObserverSnapshot
(protocol v16) for future client rendering of triangle crises (#250,
D-087). Observer emits empty vec by default; escalation system will
populate when triangles reach Active phase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:21 +01:00

476 lines
15 KiB
Rust

// 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();
}
// Blocked entities (debug, #514)
if !snapshot.blocked_entities.is_empty() {
let ids: Vec<String> = 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,
tell_state: None,
},
VisibleEntity {
entity_id: 100,
x: 18.0,
y: 10.0,
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Known,
observation: EntityVisibility::Visible,
tell_state: None,
},
VisibleEntity {
entity_id: 200,
x: 16.0,
y: 9.0,
z: 0,
kind: EntityKind::Object,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
},
],
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![],
follow_state: None,
character_pressure: None,
sound_events: vec![],
rng_seed: None,
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
}
}
#[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,
speaker_color_index: 0,
speaker_name: "Dock Worker".into(),
});
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![],
follow_state: None,
character_pressure: None,
sound_events: vec![],
rng_seed: None,
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
};
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)"));
}
}