feat(simulation): protocol v14 — POI list, examine result, player knowledge wire types

Add three new ObserverSnapshot fields for client Sprint 18 tickets:
poi_list (Vec<PoiWire>) for minimap #151, examine_result
(Option<ExamineResultWire>) for #174, player_knowledge
(Option<PlayerKnowledgeWire>) for journal #264. POI list populated
live from KG-discovered PointOfInterest components. KG dump
serializes entity/fact knowledge with confidence, source, and state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 02:29:29 +01:00
co-authored by Claude Opus 4.6
parent 2eca8e961b
commit dd3ead7a1b
8 changed files with 240 additions and 6 deletions
+6
View File
@@ -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"));
+99 -2
View File
@@ -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<u64>,
/// 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<PoiWire>,
/// 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<ExamineResultWire>,
/// 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<PlayerKnowledgeWire>,
}
/// 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<KnownEntityWire>,
/// Known facts (non-entity knowledge: POIs, events, abstract info).
pub facts: Vec<KnownFactWire>,
}
/// 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 {
+96
View File
@@ -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<SnapshotBuffer>,
sim_rng: Option<Res<SimRng>>,
) {
@@ -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<PoiWire> = 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<KnownEntityWire> = 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<KnownFactWire> = 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,
});
}
+3
View File
@@ -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
+3
View File
@@ -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
+6
View File
@@ -42,6 +42,9 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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",
+2 -1
View File
@@ -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",
+25 -3
View File
@@ -31,6 +31,9 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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