diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs new file mode 100644 index 000000000..96c90778f --- /dev/null +++ b/server/src/perception/observer/tests.rs @@ -0,0 +1,574 @@ +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, 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 +} + +#[test] +fn player_always_visible_in_snapshot() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + + 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().expect("snapshot should exist"); + assert_eq!(snapshot.version, 4); + assert_eq!(snapshot.entities.len(), 1); + assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); + assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); +} + +#[test] +fn npc_in_los_visible() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // NPC directly north of player (in forward cone) + world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); + + 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(); + assert_eq!(snapshot.entities.len(), 2); + let npc = snapshot + .entities + .iter() + .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] +fn npc_behind_wall_not_visible() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // Wall between player and NPC + let mut walkability = world.resource_mut::(); + walkability.set_walkable(&TilePosition::new(16, 14, 0), false); + // NPC behind the wall + world.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))); + + 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(); + // Only player should be visible, not the NPC behind the wall + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert!(npcs.is_empty(), "NPC behind wall should not be visible"); +} + +#[test] +fn npc_behind_player_not_visible() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // NPC far behind player (south, in blind spot) + world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))); + + 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 npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert!(npcs.is_empty(), "NPC in blind spot should not be visible"); +} + +#[test] +fn different_z_level_not_visible() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // NPC on different z-level + world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1))); + + 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 npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert!(npcs.is_empty(), "NPC on different z should not be visible"); +} + +#[test] +fn game_time_populated() { + let mut world = setup_world(32, 32); + let mut time = SimulationTime::default(); + time.tick = 7200; // 720 minutes = Evening + time.tick_rate = crate::simulation::time::TickRate::Paused; + world.insert_resource(time); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + + 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(); + assert_eq!(snapshot.game_time.time_of_day, 720); + assert_eq!( + snapshot.game_time.day_phase, + crate::simulation::time::DayPhase::Evening + ); + assert_eq!(snapshot.game_time.tick_rate, crate::simulation::time::TickRate::Paused); +} + +#[test] +fn visible_tiles_populated() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + + 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(); + assert!( + !snapshot.visible_tiles.is_empty(), + "should have visible tiles" + ); + // Observer's tile should be in the list + let has_observer_tile = snapshot + .visible_tiles + .iter() + .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, + NearbyInteractionBuffer::default(), + )); + + 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, + NearbyInteractionBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t }); + + 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, + NearbyInteractionBuffer::default(), + )) + .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, + NearbyInteractionBuffer::default(), + )) + .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, + NearbyInteractionBuffer::default(), + )) + .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, + NearbyInteractionBuffer::default(), + )) + .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" + ); +} + +#[test] +fn multiple_npcs_in_los_all_visible() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // Three NPCs in front of player, no walls + world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); + world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))); + world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0))); + + 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(); + // Player + 3 NPCs = 4 entities + assert_eq!(snapshot.entities.len(), 4); + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert_eq!(npcs.len(), 3); + assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible)); +} + +#[test] +fn npc_behind_wall_excluded_from_multi_entity_snapshot() { + let mut world = setup_world(32, 32); + // Wall at (16,14) + world + .resource_mut::() + .set_walkable(&TilePosition::new(16, 14, 0), false); + + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + )); + // NPC 1: behind wall (should be hidden) + world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0))); + // NPC 2: to the side, no wall (should be visible) + world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))); + // NPC 3: also visible + world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0))); + + 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(); + // Player + 2 visible NPCs = 3 (NPC behind wall excluded) + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded"); +}