diff --git a/CHANGELOG.md b/CHANGELOG.md index 828bd0e0c..622ac0a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Entity renderer skips redundant modulate.a writes when alpha unchanged (PR #11 review) ### Added +- Observer snapshot knowledge integration (#366) — VisibleEntity carries relationship state (D-033 color) and observation type (Visible/Remembered), remembered entities appear as fog ghosts at last known position - Knowledge graph system (#361, #362, #363, #365) — per-entity KnowledgeGraph component (D-041), StableEntityId + EntityRegistry, KnowledgeEventQueue, decay system, 4-level confidence hierarchy - Direct observation knowledge flow (#364) — perception emits DirectObservation/LeftLOS events to knowledge graph, entities entering/leaving LOS tracked - Protocol v2 decoder — extracts game_time, player_facing, visible_tiles, and per-entity visibility sectors from ObserverSnapshot v2 diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 548964aaa..1c42dbf6c 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 55f1263d0..9e7b42e75 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 978a62085..17032fe86 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index f1f8ae0ba..8602106d9 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 6dd91a8ed..2d62cbcd8 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index dc6f398ee..ee5722d86 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -89,6 +89,8 @@ pub fn generate_snapshot( z, kind, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }); } @@ -105,7 +107,7 @@ pub fn generate_snapshot( visible.len() ); buffer.snapshot = Some(ObserverSnapshot { - version: 2, + version: 3, tick: time.tick, game_time, player_facing: FacingDirection::default(), diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 051a7e25a..b51e38866 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -5,17 +5,19 @@ use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; +pub use crate::knowledge::types::{EntityVisibility, KnowledgeConfidence, RelationshipState}; pub use crate::simulation::time::DayPhase; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. /// /// v2 adds: game_time, player_facing, visible_tiles, visibility sectors. +/// v3 adds: relationship (D-033 entity color), observation (Visible/Remembered). /// Future fields: ambient sound events, internal monologue triggers, /// HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 2. + /// Protocol version for forward compatibility. Current: 3. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -95,6 +97,14 @@ pub struct VisibleEntity { pub kind: EntityKind, /// Which vision cone sector this entity falls in (D-015) pub visibility: VisibilitySector, + /// Relationship state for D-033 entity color rendering. + /// Unknown for entities not yet in the knowledge graph. + #[serde(default)] + pub relationship: RelationshipState, + /// Whether this entity is currently visible or remembered from knowledge. + /// Visible = in LOS right now. Remembered = known but not in LOS. + #[serde(default)] + pub observation: EntityVisibility, } /// Category of visible entity diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index 892a4df1a..9cec2ea43 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -205,7 +205,7 @@ impl Default for DecayThresholds { /// How an entity appears in the observer snapshot. /// Extends VisibleEntity for knowledge-based rendering. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum EntityVisibility { /// Currently in line of sight. Visible, @@ -215,3 +215,9 @@ pub enum EntityVisibility { age_ticks: u64, }, } + +impl Default for EntityVisibility { + fn default() -> Self { + Self::Visible + } +} diff --git a/server/src/perception/observer.rs b/server/src/perception/observer.rs index 798d07a07..7483ed202 100644 --- a/server/src/perception/observer.rs +++ b/server/src/perception/observer.rs @@ -8,6 +8,7 @@ use bevy_ecs::prelude::*; use std::collections::HashSet; use crate::bridge::types::*; +use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::shadowcast::compute_fov; use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig}; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; @@ -20,7 +21,8 @@ use crate::simulation::time::SimulationTime; pub fn compute_observer_snapshot( time: Res, walkability: Res, - observer_query: Query<(&TilePosition, Option<&Facing>), With>, + registry: Res, + observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With>, all_entities: Query<( Entity, &TilePosition, @@ -29,7 +31,7 @@ pub fn compute_observer_snapshot( )>, mut buffer: ResMut, ) { - let Ok((observer_pos, facing_opt)) = observer_query.single() else { + let Ok((observer_pos, facing_opt, observer_kg)) = observer_query.single() else { return; }; @@ -74,8 +76,9 @@ pub fn compute_observer_snapshot( .map(|&(x, y, sector)| ((x, y), sector)) .collect(); - // Step 5: Filter entities by visibility + // Step 5: Filter entities by visibility, overlay knowledge let mut entities = Vec::new(); + let mut visible_entity_bits: HashSet = HashSet::new(); for (entity, pos, is_player, is_npc) in all_entities.iter() { // Different z-level: not visible if pos.z != z { @@ -101,17 +104,83 @@ pub fn compute_observer_snapshot( .copied() .unwrap_or(VisibilitySector::Peripheral); + // Look up relationship from knowledge graph (D-033 entity color) + let relationship = if is_player.is_some() { + RelationshipState::Known // Self + } else if let Some(stable_id) = registry.to_stable(entity) { + observer_kg.relationship_with(&stable_id) + } else { + RelationshipState::Unknown + }; + + visible_entity_bits.insert(entity.to_bits()); entities.push(VisibleEntity { - entity_id: entity.to_bits(), // Temporary: use entity.to_bits() until #362 StableEntityId + entity_id: entity.to_bits(), x: rx, y: ry, z: rz, kind, visibility: sector, + relationship, + observation: EntityVisibility::Visible, }); } - // Step 6: Build GameTime from SimulationTime + // Step 6: Add remembered entities from knowledge graph (#366) + // Entities the observer knows about but can't currently see. + for (stable_id, knowledge) in observer_kg.known_entities_iter() { + // Skip if currently visible (already in the entity list) + if let Some(entity) = registry.to_entity(stable_id) { + if visible_entity_bits.contains(&entity.to_bits()) { + continue; + } + } + + // Skip if no known position (never directly observed) + let Some(position) = knowledge.last_known_position else { + continue; + }; + + // Skip if remembered position is on a different z-level than the observer + if position.z != z { + continue; + } + + // Skip if the remembered tile is currently visible — if the player + // can see the tile and the entity isn't there, don't show a ghost. + if visible_positions.contains(&(position.x, position.y)) { + continue; + } + + // Direct confidence means the entity should be in LOS — if it isn't, + // that's a transient data inconsistency. Skip rather than show a ghost. + if knowledge.confidence == KnowledgeConfidence::Direct { + continue; + } + + let (rx, ry, rz) = position.to_render_coords(); + let age_ticks = time.tick.saturating_sub(knowledge.last_observed_tick); + let entity_id = registry + .to_entity(stable_id) + .map(|e| e.to_bits()) + .unwrap_or(stable_id.0); + + entities.push(VisibleEntity { + entity_id, + x: rx, + y: ry, + z: rz, + kind: EntityKind::Npc, // Remembered entities are NPCs (only NPCs are tracked) + visibility: VisibilitySector::Forward, // Not meaningful for remembered entities + relationship: knowledge.relationship, + observation: EntityVisibility::Remembered { + confidence: knowledge.confidence, + age_ticks, + }, + }); + } + + // Step 7: Build GameTime from SimulationTime let game_time = GameTime { day: time.day(), time_of_day: time.time_of_day_minutes(), @@ -120,15 +189,16 @@ pub fn compute_observer_snapshot( }; tracing::trace!( - "compute_observer_snapshot: tick={}, entities={}, tiles={}", + "compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}", time.tick, - entities.len(), + visible_entity_bits.len(), + entities.len() - visible_entity_bits.len(), visible_tiles.len(), ); - // Step 7: Assemble v2 snapshot + // Step 8: Assemble snapshot (v3: added relationship + observation fields) buffer.snapshot = Some(ObserverSnapshot { - version: 2, + version: 3, tick: time.tick, game_time, player_facing: facing, @@ -140,15 +210,17 @@ pub fn compute_observer_snapshot( #[cfg(test)] mod tests { use super::*; + use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::vision_cone::Facing; use bevy_ecs::world::World; - /// Helper: set up a test world with player and walkability map + /// Helper: set up a test world with player, walkability map, and knowledge resources fn setup_world(width: i32, height: i32) -> World { let mut world = World::new(); world.insert_resource(SimulationTime::default()); world.insert_resource(WalkabilityMap::new(width, height, 1)); world.init_resource::(); + world.init_resource::(); world } @@ -159,6 +231,7 @@ mod tests { PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), + KnowledgeGraph::new(), )); let mut schedule = bevy_ecs::schedule::Schedule::default(); @@ -167,9 +240,10 @@ mod tests { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 2); + assert_eq!(snapshot.version, 3); assert_eq!(snapshot.entities.len(), 1); assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); + assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); } #[test] @@ -179,6 +253,7 @@ mod tests { PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), + KnowledgeGraph::new(), )); // NPC directly north of player (in forward cone) world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); @@ -196,6 +271,7 @@ mod tests { .find(|e| matches!(e.kind, EntityKind::Npc)) .expect("NPC should be visible"); assert_eq!(npc.visibility, VisibilitySector::Forward); + assert_eq!(npc.observation, EntityVisibility::Visible); } #[test] @@ -205,6 +281,7 @@ mod tests { PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), + KnowledgeGraph::new(), )); // Wall between player and NPC let mut walkability = world.resource_mut::(); @@ -234,6 +311,7 @@ mod tests { PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), + KnowledgeGraph::new(), )); // NPC far behind player (south, in blind spot) world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))); @@ -259,6 +337,7 @@ mod tests { PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), + KnowledgeGraph::new(), )); // NPC on different z-level world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1))); @@ -288,6 +367,7 @@ mod tests { PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), + KnowledgeGraph::new(), )); let mut schedule = bevy_ecs::schedule::Schedule::default(); @@ -311,6 +391,7 @@ mod tests { PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), + KnowledgeGraph::new(), )); let mut schedule = bevy_ecs::schedule::Schedule::default(); @@ -330,4 +411,294 @@ mod tests { .any(|t| t.x == 16 && t.y == 16 && t.z == 0); assert!(has_observer_tile, "observer tile should be visible"); } + + #[test] + fn visible_npc_has_relationship_from_knowledge() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player knows NPC is hostile + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::Hostile); + + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + )); + + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + let npc_entity = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible"); + assert_eq!(npc_entity.relationship, RelationshipState::Hostile); + assert_eq!(npc_entity.observation, EntityVisibility::Visible); + } + + #[test] + fn remembered_entity_appears_as_ghost() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC exists far behind the player (not visible) + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 30, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player previously saw NPC at (16, 28) — behind the player (south), + // well beyond peripheral range. The tile is NOT in the player's FOV. + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 28, 0), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + world.insert_resource(SimulationTime { tick: 100, paused: false }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + // Should have player (visible) + NPC (remembered) + let remembered: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) + .collect(); + assert_eq!(remembered.len(), 1, "should have one remembered entity"); + assert_eq!(remembered[0].relationship, RelationshipState::PersonOfInterest); + + // Remembered entity at last_known_position (16, 28), not actual (16, 30) + assert_eq!(remembered[0].x, 16.5); + assert_eq!(remembered[0].y, 28.5); + + if let EntityVisibility::Remembered { confidence, age_ticks } = &remembered[0].observation { + assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails); + assert_eq!(*age_ticks, 50); // tick 100 - last_observed 50 + } + } + + #[test] + fn direct_confidence_not_shown_as_remembered() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC exists but not in LOS + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 10, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Knowledge still shows Direct (transient inconsistency) + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + // Still Direct — don't show as ghost + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + )) + .id(); + registry.register(player); + + // Wall blocks actual NPC position + let mut walkability = world.resource_mut::(); + walkability.set_walkable(&TilePosition::new(16, 12, 0), false); + + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let remembered: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) + .collect(); + assert!( + remembered.is_empty(), + "Direct-confidence entities should not appear as remembered ghosts" + ); + } + + #[test] + fn remembered_entity_on_visible_tile_not_shown() { + // If the player can see a tile and the entity isn't there, + // don't show a ghost — the player knows it moved. + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(30, 30, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player remembers NPC at (16, 15) — a tile the player can currently see + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let remembered: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) + .collect(); + assert!( + remembered.is_empty(), + "ghost should not appear on a tile the player can currently see" + ); + } + + #[test] + fn remembered_entity_different_z_not_shown() { + // Remembered entity on a different z-level should not appear + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(5, 5, 1))) + .id(); + let npc_sid = registry.register(npc); + + // Player remembers NPC at z=1, but player is at z=0 + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(5, 5, 1), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let remembered: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) + .collect(); + assert!( + remembered.is_empty(), + "remembered entity on different z-level should not appear in snapshot" + ); + } + + #[test] + fn knowledge_without_position_not_shown() { + // Entity known via gossip (no last_known_position) should not appear + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(5, 5, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player knows about NPC but has never seen it (no position) + let mut kg = KnowledgeGraph::new(); + // Insert knowledge manually without a position + kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: 50, + confidence: KnowledgeConfidence::KnowsOf, + source: crate::knowledge::KnowledgeSource::Background, + state: crate::knowledge::KnowledgeState::Active, + relationship: RelationshipState::PersonOfInterest, + known_attributes: std::collections::BTreeMap::new(), + }); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_observer_snapshot); + schedule.run(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + let remembered: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) + .collect(); + assert!( + remembered.is_empty(), + "entity without last_known_position should not appear as ghost" + ); + } } diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 699b66902..c55bfa4a5 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -33,7 +33,7 @@ fn snapshot_roundtrip_over_unix_socket() { let bridge = LocalBridge::accept(&server_path).expect("failed to accept"); let snapshot = ObserverSnapshot { - version: 2, + version: 3, tick: 42, game_time: GameTime { day: 0, @@ -49,6 +49,8 @@ fn snapshot_roundtrip_over_unix_socket() { z: 0, kind: EntityKind::Npc, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }], visible_tiles: vec![], }; diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 0aaf8a5bf..c15cc484a 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -19,7 +19,7 @@ fn snapshot_roundtrip_over_tcp() { let bridge = TcpBridge::accept_on(listener).expect("failed to accept"); let snapshot = ObserverSnapshot { - version: 2, + version: 3, tick: 42, game_time: GameTime { day: 0, @@ -35,6 +35,8 @@ fn snapshot_roundtrip_over_tcp() { z: 0, kind: EntityKind::Npc, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }], visible_tiles: vec![], }; diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 500ab4096..bbced55fc 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -61,7 +61,7 @@ fn player_moves_north_through_full_pipeline() { rmp_serde::from_slice(&response).expect("deserialize snapshot"); // Snapshot captures state at end of tick 0 (before advance_tick increments to 1) - assert_eq!(snapshot.version, 2); + assert_eq!(snapshot.version, 3); assert_eq!(snapshot.tick, 0); assert_eq!(snapshot.entities.len(), 1); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index e8d9b045c..d421b743c 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -18,7 +18,7 @@ fn write_fixture(name: &str, bytes: &[u8]) { /// Helper to create a minimal v2 snapshot for fixtures fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { ObserverSnapshot { - version: 2, + version: 3, tick, game_time: GameTime { day: 0, @@ -45,6 +45,8 @@ fn generate_msgpack_fixtures() { z: 0, kind: EntityKind::Npc, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }], ); write_fixture( @@ -86,6 +88,8 @@ fn generate_msgpack_fixtures() { z: 0, kind: EntityKind::Player, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }], ); write_fixture( @@ -104,6 +108,8 @@ fn generate_msgpack_fixtures() { z: 0, kind: EntityKind::Player, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Known, + observation: EntityVisibility::Visible, }, VisibleEntity { entity_id: 2, @@ -112,6 +118,8 @@ fn generate_msgpack_fixtures() { z: 0, kind: EntityKind::Npc, visibility: VisibilitySector::Peripheral, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }, VisibleEntity { entity_id: 3, @@ -120,6 +128,8 @@ fn generate_msgpack_fixtures() { z: 1, kind: EntityKind::Object, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }, VisibleEntity { entity_id: 4, @@ -128,6 +138,8 @@ fn generate_msgpack_fixtures() { z: -1, kind: EntityKind::Terrain, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }, ], ); @@ -138,7 +150,7 @@ fn generate_msgpack_fixtures() { // v2 snapshot with visible_tiles and game_time populated let snapshot_v2_full = ObserverSnapshot { - version: 2, + version: 3, tick: 500, game_time: GameTime { day: 1, @@ -154,6 +166,8 @@ fn generate_msgpack_fixtures() { z: 0, kind: EntityKind::Player, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }], visible_tiles: vec![ VisibleTile { diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 20cdcab27..a768136fe 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -7,7 +7,7 @@ use std::fs; /// Helper to create a minimal v2 snapshot for tests fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { ObserverSnapshot { - version: 2, + version: 3, tick, game_time: GameTime { day: 0, @@ -32,13 +32,15 @@ fn observer_snapshot_roundtrip() { z: 0, kind: EntityKind::Npc, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }], ); let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.version, 2); + assert_eq!(decoded.version, 3); assert_eq!(decoded.tick, 42); assert_eq!(decoded.entities.len(), 1); assert_eq!(decoded.entities[0].entity_id, 1); @@ -158,6 +160,8 @@ fn all_entity_kind_variants_roundtrip() { z: 0, kind, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }; let snapshot = test_snapshot(0, vec![entity]); let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -174,7 +178,7 @@ fn all_entity_kind_variants_roundtrip() { #[test] fn snapshot_v2_fields_roundtrip() { let snapshot = ObserverSnapshot { - version: 2, + version: 3, tick: 100, game_time: GameTime { day: 3, @@ -190,6 +194,8 @@ fn snapshot_v2_fields_roundtrip() { z: 0, kind: EntityKind::Player, visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, }], visible_tiles: vec![ VisibleTile { @@ -210,7 +216,7 @@ fn snapshot_v2_fields_roundtrip() { let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.version, 2); + assert_eq!(decoded.version, 3); assert_eq!(decoded.game_time.day, 3); assert_eq!(decoded.game_time.time_of_day, 720); assert_eq!(decoded.game_time.day_phase, DayPhase::Evening); @@ -238,7 +244,7 @@ fn all_facing_direction_variants_roundtrip() { for dir in directions { let snapshot = ObserverSnapshot { - version: 2, + version: 3, tick: 0, game_time: GameTime { day: 0,