diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index d03a30912..8d0292ed2 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -308,6 +308,9 @@ mod tests { follow_state: None, sound_events: vec![], rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, } } @@ -438,6 +441,9 @@ mod tests { follow_state: None, sound_events: vec![], rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 8c2697358..793d99d65 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -5,7 +5,9 @@ use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; -pub use crate::knowledge::types::{EntityVisibility, KnowledgeConfidence, RelationshipState}; +pub use crate::knowledge::types::{ + EntityVisibility, KnowledgeConfidence, KnowledgeState, RelationshipState, +}; pub use crate::simulation::time::{DayPhase, TickRate}; /// Wire protocol version for ObserverSnapshot. @@ -15,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 13; +pub const PROTOCOL_VERSION: u8 = 14; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -34,6 +36,9 @@ pub const PROTOCOL_VERSION: u8 = 13; /// v12 adds: conversation_events, conversation_ended (#247, D-078 NPC-to-NPC conversations). /// v13 adds: tell_state on VisibleEntity (#90, D-024 tell system — for future client use), /// follow_state (#241, follow mechanic HUD state). +/// v14 adds: poi_list (#151, discovered POIs for minimap rendering), +/// examine_result (#174, character-filtered observation text from Examine verb), +/// player_knowledge (#264, partial KG dump for journal/knowledge panel). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { @@ -110,6 +115,22 @@ pub struct ObserverSnapshot { /// None when the RNG resource is unavailable (should not occur in practice). #[serde(default, skip_serializing_if = "Option::is_none")] pub rng_seed: Option, + /// Discovered POIs for minimap rendering (#151, D-013). + /// Contains all POIs the observer has discovered (fact in KG). + /// Client renders nearby POIs as dots, distant POIs as directional arrows. + /// Empty when no POIs have been discovered. + #[serde(default)] + pub poi_list: Vec, + /// Character-filtered observation text from Examine verb (#174, #242). + /// Present when an examine interaction completed this tick. + /// Client displays as non-interactive overlay, auto-dismisses after 4-6 seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub examine_result: Option, + /// Partial knowledge graph dump for journal/knowledge panel (#264, D-041). + /// Updated periodically (not every tick — only when KG changes). + /// Client renders as a read-only journal grouped by entity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_knowledge: Option, } /// Game time data for client display (D-031) @@ -536,6 +557,82 @@ pub struct DialogueResponseEvent { pub speaker_name: String, } +/// A discovered POI crossing the wire for minimap rendering (#151). +/// Derived from PointOfInterest component + KG fact lookup. +/// Client converts position to player-relative vector for minimap display. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PoiWire { + /// POI identifier (matches poi_id in PointOfInterest component). + pub poi_id: String, + /// Display name for minimap label. + pub name: String, + /// World position in simulation tile coordinates. + /// Client converts to player-relative vector for compass placement. + pub x: i32, + pub y: i32, + pub z: i32, + /// Category for icon/color selection on minimap. + pub category: crate::simulation::poi::PoiCategory, +} + +/// Character-filtered observation text from Examine verb (#174, #242). +/// The server runs the examine through the observer's KG to produce +/// text appropriate to what the character knows/sees. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExamineResultWire { + /// Wire-format entity ID of the examined entity. + pub entity_id: u64, + /// Character-filtered observation text. + pub text: String, + /// Observer's confidence level about this entity at time of examine. + pub confidence: KnowledgeConfidence, +} + +/// Partial knowledge graph dump for the journal panel (#264, D-041). +/// Sent when KG state changes. Contains entity knowledge and fact knowledge +/// that the observer has accumulated. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlayerKnowledgeWire { + /// Known entities with their knowledge metadata. + pub entities: Vec, + /// Known facts (non-entity knowledge: POIs, events, abstract info). + pub facts: Vec, +} + +/// A single entity knowledge entry for the journal wire format (#264). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KnownEntityWire { + /// Wire-format entity ID. + pub entity_id: u64, + /// Display name (from known_attributes if available, else "Unknown"). + pub name: String, + /// Confidence level (Suspects / KnowsOf / KnowsDetails / Direct). + pub confidence: KnowledgeConfidence, + /// How the knowledge was acquired. + pub source: String, + /// Logical state (Active / Contradicted / Stale). + pub state: KnowledgeState, + /// Relationship assessment for color rendering. + pub relationship: RelationshipState, + /// Last tick this entity was observed. + pub last_observed_tick: u64, +} + +/// A single fact knowledge entry for the journal wire format (#264). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KnownFactWire { + /// Fact identifier (e.g. "poi.docking_bay_7", "contraband.ring_exists"). + pub fact_id: String, + /// Confidence level. + pub confidence: KnowledgeConfidence, + /// How the fact was acquired. + pub source: String, + /// Logical state. + pub state: KnowledgeState, + /// Tick when this fact was learned. + pub acquired_tick: u64, +} + /// Snapshot buffer resource for staging outgoing ObserverSnapshots #[derive(Resource, Debug, Default)] pub struct SnapshotBuffer { diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 4720e806a..e308ac550 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -24,6 +24,7 @@ use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue}; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::poi::PointOfInterest; use crate::simulation::rng::SimRng; use crate::simulation::sound::SoundEventQueue; use crate::simulation::stance::Stance; @@ -94,6 +95,7 @@ pub fn compute_observer_snapshot( Option<&crate::npc::tell_state::DerivedTellState>, )>, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + poi_query: Query<&PointOfInterest>, mut buffer: ResMut, sim_rng: Option>, ) { @@ -272,6 +274,97 @@ pub fn compute_observer_snapshot( }) }); + // Collect discovered POIs for minimap (#151, D-013) + // A POI is "discovered" if the observer's KG contains fact "poi.{poi_id}". + let poi_list: Vec = poi_query + .iter() + .filter(|poi| observer_kg.knows_fact(&poi.fact_id())) + .map(|poi| PoiWire { + poi_id: poi.poi_id.clone(), + name: poi.name.clone(), + x: poi.position.x, + y: poi.position.y, + z: poi.position.z, + category: poi.category, + }) + .collect(); + + // Build player knowledge dump for journal panel (#264, D-041). + // Sends full KG state — client-side filtering for display grouping. + let player_knowledge = { + let kg_entities: Vec = observer_kg + .known_entities_iter() + .map(|(sid, ek)| { + let name = ek + .known_attributes + .get("name") + .cloned() + .unwrap_or_else(|| "Unknown".to_string()); + let source = match &ek.source { + crate::knowledge::types::KnowledgeSource::DirectObservation { .. } => { + "DirectObservation".to_string() + } + crate::knowledge::types::KnowledgeSource::Heard { .. } => "Heard".to_string(), + crate::knowledge::types::KnowledgeSource::ToldBy { source_id, .. } => { + format!("ToldBy({})", source_id.0) + } + crate::knowledge::types::KnowledgeSource::Inferred { .. } => { + "Inferred".to_string() + } + crate::knowledge::types::KnowledgeSource::Background => { + "Background".to_string() + } + }; + KnownEntityWire { + entity_id: sid.0, + name, + confidence: ek.confidence, + source, + state: ek.state, + relationship: ek.relationship, + last_observed_tick: ek.last_observed_tick, + } + }) + .collect(); + + let kg_facts: Vec = observer_kg + .known_facts_iter() + .map(|(fid, fk)| { + let source = match &fk.source { + crate::knowledge::types::KnowledgeSource::DirectObservation { .. } => { + "DirectObservation".to_string() + } + crate::knowledge::types::KnowledgeSource::Heard { .. } => "Heard".to_string(), + crate::knowledge::types::KnowledgeSource::ToldBy { source_id, .. } => { + format!("ToldBy({})", source_id.0) + } + crate::knowledge::types::KnowledgeSource::Inferred { .. } => { + "Inferred".to_string() + } + crate::knowledge::types::KnowledgeSource::Background => { + "Background".to_string() + } + }; + KnownFactWire { + fact_id: fid.0.clone(), + confidence: fk.confidence, + source, + state: fk.state, + acquired_tick: fk.acquired_tick, + } + }) + .collect(); + + if kg_entities.is_empty() && kg_facts.is_empty() { + None + } else { + Some(PlayerKnowledgeWire { + entities: kg_entities, + facts: kg_facts, + }) + } + }; + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -292,6 +385,9 @@ pub fn compute_observer_snapshot( follow_state, sound_events, rng_seed: sim_rng.as_deref().map(|r| r.seed()), + poi_list, + examine_result: None, // Populated by examine system when #242 lands + player_knowledge, }); } diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 886224372..09fba2fe9 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -67,6 +67,9 @@ fn snapshot_roundtrip_over_unix_socket() { conversation_ended: vec![], follow_state: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 0e949d56b..28df2e0f7 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -53,6 +53,9 @@ fn snapshot_roundtrip_over_tcp() { conversation_ended: vec![], follow_state: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, }; bridge diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 86a7346a9..13fd4e8d0 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -42,6 +42,9 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot conversation_ended: vec![], follow_state: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, } } @@ -228,6 +231,9 @@ fn generate_msgpack_fixtures() { conversation_ended: vec![], follow_state: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, }; write_fixture( "snapshot_v2_full", diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 1f4f346c8..71de32702 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -67,11 +67,12 @@ "player_facing": "North", "player_inventory": [], "player_stance": "Sprint", + "poi_list": [], "rng_seed": 42, "scan_events": [], "sound_events": [], "tick": 8, - "version": 13, + "version": 14, "visible_tiles": [ { "tile_kind": "Wall", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 48af4ebdb..637def69f 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -31,6 +31,9 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { conversation_ended: vec![], follow_state: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, } } @@ -276,6 +279,9 @@ fn snapshot_v2_fields_roundtrip() { conversation_ended: vec![], follow_state: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -330,7 +336,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 13, + PROTOCOL_VERSION, 14, "bump this assertion when protocol version changes" ); } @@ -375,6 +381,9 @@ fn all_facing_direction_variants_roundtrip() { conversation_ended: vec![], follow_state: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); @@ -1418,8 +1427,8 @@ fn serde_default_fields_fill_in_when_missing_from_wire() { let decoded: ObserverSnapshot = serde_json::from_value(minimal_json).expect("minimal JSON must deserialize"); - // Version and required fields present - assert_eq!(decoded.version, PROTOCOL_VERSION); + // Version matches what was in the wire (13, simulating older server) + assert_eq!(decoded.version, 13); assert_eq!(decoded.tick, 42); assert_eq!(decoded.entities.len(), 1); @@ -1437,6 +1446,19 @@ fn serde_default_fields_fill_in_when_missing_from_wire() { decoded.follow_state.is_none(), "follow_state must default to None when absent from wire" ); + // v14 fields default correctly when absent from older wire format + assert!( + decoded.poi_list.is_empty(), + "poi_list must default to empty when absent from wire" + ); + assert!( + decoded.examine_result.is_none(), + "examine_result must default to None when absent from wire" + ); + assert!( + decoded.player_knowledge.is_none(), + "player_knowledge must default to None when absent from wire" + ); } /// A snapshot with version != PROTOCOL_VERSION can be detected by checking