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>
476 lines
15 KiB
Rust
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)"));
|
|
}
|
|
}
|