use super::*; use crate::knowledge::types::KnowledgeState; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::monologue::MonologueBuffer; use bevy_ecs::world::World; /// Helper: set up a test world with resources for the two-stage observer pipeline. 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.init_resource::(); world.init_resource::(); world } /// Run the two-stage observer pipeline: geometry + snapshot. fn run_observer_pipeline(world: &mut World) { let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(( compute_visibility_geometry, compute_observer_snapshot.after(compute_visibility_geometry), )); schedule.run(world); } /// Run the full pipeline including interaction system. fn run_full_pipeline(world: &mut World) { let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(( crate::simulation::interaction::compute_nearby_interactions, compute_visibility_geometry, compute_observer_snapshot .after(compute_visibility_geometry) .after(crate::simulation::interaction::compute_nearby_interactions), )); schedule.run(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(), MonologueBuffer::default(), )); run_observer_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); assert_eq!(snapshot.version, 6); 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(), MonologueBuffer::default(), )); // NPC directly north of player (in forward cone) world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); run_observer_pipeline(&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(), MonologueBuffer::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))); run_observer_pipeline(&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(), MonologueBuffer::default(), )); // NPC far behind player (south, in blind spot) world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))); run_observer_pipeline(&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(), MonologueBuffer::default(), )); // NPC on different z-level world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1))); run_observer_pipeline(&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(), MonologueBuffer::default(), )); run_observer_pipeline(&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(), MonologueBuffer::default(), )); run_observer_pipeline(&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(), MonologueBuffer::default(), )); world.insert_resource(registry); run_observer_pipeline(&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(), MonologueBuffer::default(), )) .id(); registry.register(player); world.insert_resource(registry); world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t }); run_observer_pipeline(&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(), MonologueBuffer::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); run_observer_pipeline(&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(), MonologueBuffer::default(), )) .id(); registry.register(player); world.insert_resource(registry); run_observer_pipeline(&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(), MonologueBuffer::default(), )) .id(); registry.register(player); world.insert_resource(registry); run_observer_pipeline(&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(), MonologueBuffer::default(), )) .id(); registry.register(player); world.insert_resource(registry); run_observer_pipeline(&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(), MonologueBuffer::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))); run_observer_pipeline(&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(), MonologueBuffer::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))); run_observer_pipeline(&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"); } #[test] fn poi_interaction_gets_observe_first_priority() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC in close range, directly north of player and in LOS let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, )) .id(); let npc_sid = registry.register(npc); // Player knows NPC as PersonOfInterest let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, NearbyInteractionBuffer::default(), MonologueBuffer::default(), )) .id(); registry.register(player); world.insert_resource(registry); // Run full pipeline: interaction computes default priority, // then observer applies POI adjustment run_full_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); let interaction = &snapshot.nearby_interactions[0]; // POI: Observe takes priority over Talk assert_eq!(interaction.verbs[0].kind, VerbKind::ExamineNpc); assert_eq!(interaction.verbs[0].priority, 1); assert_eq!(interaction.verbs[1].kind, VerbKind::Talk); assert_eq!(interaction.verbs[1].priority, 2); } // ----------------------------------------------------------------------- // v6 field tests (Hoshe QA, Sprint 6 — #449) // ----------------------------------------------------------------------- #[test] fn snapshot_v6_fields_default_through_pipeline() { // Until #417 (stance) and #424 (inventory) wire up the components, // the observer system should produce Walk stance and empty inventory. let mut world = setup_world(32, 32); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), )); run_observer_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); assert_eq!(snapshot.version, 6, "should be protocol v6"); assert_eq!(snapshot.player_stance, MovementStance::Walk, "default stance is Walk"); assert!(snapshot.player_inventory.is_empty(), "default inventory is empty"); } #[test] fn snapshot_v6_version_is_protocol_version() { let mut world = setup_world(32, 32); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), )); run_observer_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!( snapshot.version, crate::bridge::types::PROTOCOL_VERSION, "snapshot version must match PROTOCOL_VERSION constant" ); }