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:
@@ -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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user