use super::*; use crate::knowledge::types::{KnowledgeState, ObserverAccess}; 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.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.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(), contradicted_claim: None, }, ); 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: NW diagonal, in forward cone (should be visible) world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))); // NPC 3: NE diagonal, in forward cone (should be visible) 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 + 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.player_stance, MovementStance::Walk, "default stance is Walk" ); assert!( snapshot.player_inventory.is_empty(), "default inventory is empty" ); } // ----------------------------------------------------------------------- // Phase 2 verb filter tests (#422, D-057) // ----------------------------------------------------------------------- #[test] fn phase2_confront_injected_for_npc_with_knows_details() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC at close range, directly north 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 has KnowsDetails confidence on NPC let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); kg.observe_entity_leaving_los(&npc_sid, 60); // Now at KnowsDetails (one step below Direct) 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(&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]; // Should have Talk, ExamineNpc, Follow, AND Confront (Phase 2 injected) assert_eq!(interaction.verbs.len(), 4); let confront = interaction .verbs .iter() .find(|v| v.kind == VerbKind::Confront); assert!( confront.is_some(), "Confront should be injected for KnowsDetails+" ); assert_eq!(confront.unwrap().priority, 3); assert_eq!(confront.unwrap().label, "Confront"); } #[test] fn phase2_no_confront_without_knows_details() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, )) .id(); let npc_sid = registry.register(npc); // Player only Suspects this NPC (below KnowsDetails threshold) let mut kg = KnowledgeGraph::new(); kg.entities.insert( npc_sid, crate::knowledge::EntityKnowledge { last_known_position: Some(TilePosition::new(16, 15, 0)), last_observed_tick: 50, last_updated_tick: 50, confidence: KnowledgeConfidence::Suspects, source: crate::knowledge::KnowledgeSource::Background, state: KnowledgeState::Active, relationship: RelationshipState::Unknown, known_attributes: std::collections::BTreeMap::new(), contradicted_claim: None, }, ); 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(&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]; let confront = interaction .verbs .iter() .find(|v| v.kind == VerbKind::Confront); assert!( confront.is_none(), "Confront should NOT appear for Suspects confidence" ); } #[test] fn phase2_no_confront_at_mid_range() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC at mid range (distance 4, > CLOSE_RANGE=2) let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 12, 0), crate::simulation::interaction::Interactable, )) .id(); let npc_sid = registry.register(npc); // Player has KnowsDetails let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 12, 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_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]; // Mid range: only ExamineNpc, no Talk, no Confront let confront = interaction .verbs .iter() .find(|v| v.kind == VerbKind::Confront); assert!(confront.is_none(), "Confront requires close range"); } #[test] fn phase2_contradiction_marks_interaction() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, )) .id(); let npc_sid = registry.register(npc); // Player has contradicted knowledge about NPC let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; 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(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); assert!( snapshot.nearby_interactions[0].contradicted, "interaction should be marked contradicted" ); } #[test] fn phase2_no_contradiction_for_active_knowledge() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, )) .id(); let npc_sid = registry.register(npc); // Player has Active (normal) knowledge — no contradiction let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); 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(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); assert!( !snapshot.nearby_interactions[0].contradicted, "interaction should NOT be contradicted for Active knowledge" ); } #[test] fn phase2_non_container_keeps_default_labels() { // Readable objects keep their Phase 1 verb labels unchanged through Phase 2. // Regression guard: archetype-verb differentiation is Phase 6 detail — not present // in the current server per the development cascade (CLAUDE.md). D-057 superseded. // If this test fails, a character-class relabelling branch was reintroduced before // Phase 6 scope is confirmed by the team lead. let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let readable = world .spawn(( TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, ObjectType::Readable, )) .id(); registry.register(readable); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), )) .id(); registry.register(player); world.insert_resource(registry); 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]; let read_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Read); assert_eq!( read_verb.unwrap().label, "Read", "Readable labels unchanged" ); } #[test] fn phase2_object_type_carried_through_snapshot() { // NearbyInteraction.object_type should be populated from Phase 1 let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let terminal = world .spawn(( TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, ObjectType::Terminal, )) .id(); registry.register(terminal); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), )) .id(); registry.register(player); world.insert_resource(registry); run_full_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); assert_eq!( snapshot.nearby_interactions[0].object_type, Some(ObjectType::Terminal), "object_type should be carried through from Phase 1" ); } #[test] fn phase2_npc_object_type_is_none() { // NPCs should have object_type = None let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, )) .id(); registry.register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), )) .id(); registry.register(player); world.insert_resource(registry); run_full_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); assert_eq!( snapshot.nearby_interactions[0].object_type, None, "NPC should have object_type=None" ); } // ----------------------------------------------------------------------- // Sprint suppression end-to-end (#419 QA, D-055) // ----------------------------------------------------------------------- #[test] fn sprint_suppresses_interactions_through_full_pipeline() { use crate::simulation::stance::Stance; let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC at close range, directly north in LOS let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, )) .id(); registry.register(npc); // Player in Sprint stance let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), Stance(MovementStance::Sprint), )) .id(); registry.register(player); world.insert_resource(registry); run_full_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); // NPC should be VISIBLE (sprint suppresses interpretation, not data per D-055) let npcs: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.kind, EntityKind::Npc)) .collect(); assert_eq!(npcs.len(), 1, "NPC should still be visible during sprint"); // But interactions should be empty assert!( snapshot.nearby_interactions.is_empty(), "sprint should suppress all nearby_interactions in final snapshot" ); // Stance should be Sprint in snapshot assert_eq!(snapshot.player_stance, MovementStance::Sprint); } #[test] fn phase2_poi_with_confront_verb_order() { // POI NPC with KnowsDetails: ExamineNpc(1), Talk(2), Confront(3) let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, )) .id(); let npc_sid = registry.register(npc); let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 15, 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); run_full_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); let verbs = &snapshot.nearby_interactions[0].verbs; assert_eq!( verbs.len(), 4, "POI+KnowsDetails: ExamineNpc + Talk + Follow + Confront" ); // POI flips ExamineNpc to priority 1, Talk to 2, Follow at 3, Confront at 3 assert_eq!(verbs[0].kind, VerbKind::ExamineNpc); assert_eq!(verbs[0].priority, 1); assert_eq!(verbs[1].kind, VerbKind::Talk); assert_eq!(verbs[1].priority, 2); // Follow and Confront both at priority 3 — sorted by VerbKind discriminant assert!(verbs.iter().any(|v| v.kind == VerbKind::Follow)); assert!(verbs.iter().any(|v| v.kind == VerbKind::Confront)); } // ----------------------------------------------------------------------- // Inventory through observer pipeline (#424 QA, D-065) // ----------------------------------------------------------------------- #[test] fn carried_item_appears_in_snapshot_inventory() { // D-065: player_inventory populated via collect_inventory_for through full pipeline use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), )) .id(); let player_sid = registry.register(player); // Item carried by player (no TilePosition — in inventory) let item = world .spawn(( CarriedBy(player_sid), ItemName("Manifest Copy".into()), InventorySlot(0), )) .id(); registry.register(item); world.insert_resource(registry); run_observer_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!( snapshot.player_inventory.len(), 1, "carried item should appear in snapshot" ); assert_eq!(snapshot.player_inventory[0].name, "Manifest Copy"); assert_eq!(snapshot.player_inventory[0].slot, 0); } #[test] fn carried_item_not_in_visible_entities() { // D-065 info boundary: carried items have no TilePosition, so they // must NOT appear in the visible entity list (spatial queries skip them). use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), )) .id(); let player_sid = registry.register(player); // Item in inventory: has CarriedBy but NO TilePosition let item = world .spawn(( CarriedBy(player_sid), ItemName("Corridor Access Token".into()), InventorySlot(1), )) .id(); registry.register(item); world.insert_resource(registry); run_observer_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); // Only the player should be in visible entities — carried item has no TilePosition assert_eq!( snapshot.entities.len(), 1, "carried item without TilePosition must not appear in visible entities" ); assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); // But it IS in the inventory assert_eq!(snapshot.player_inventory.len(), 1); } #[test] fn multiple_carried_items_sorted_in_snapshot() { // D-065: 3 v0.1 items, verify sorting by slot through pipeline use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), )) .id(); let player_sid = registry.register(player); // Spawn 3 v0.1 items in reverse slot order for (slot, name) in [ (2, "Personal Comm Log"), (0, "Manifest Copy"), (1, "Corridor Access Token"), ] { let item = world .spawn(( CarriedBy(player_sid), ItemName(name.into()), InventorySlot(slot), )) .id(); registry.register(item); } world.insert_resource(registry); run_observer_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.player_inventory.len(), 3); assert_eq!(snapshot.player_inventory[0].slot, 0); assert_eq!(snapshot.player_inventory[0].name, "Manifest Copy"); assert_eq!(snapshot.player_inventory[1].slot, 1); assert_eq!(snapshot.player_inventory[1].name, "Corridor Access Token"); assert_eq!(snapshot.player_inventory[2].slot, 2); assert_eq!(snapshot.player_inventory[2].name, "Personal Comm Log"); } // ----------------------------------------------------------------------- // Sprint anomaly detection tests (#428, D-055) // ----------------------------------------------------------------------- #[test] fn sprint_past_contradicted_npc_queues_anomaly() { use crate::simulation::monologue::SprintAnomalyQueue; use crate::simulation::stance::Stance; let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC in LOS, directly north let npc = world .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) .id(); let npc_sid = registry.register(npc); // Player has Contradicted knowledge about the NPC let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, NearbyInteractionBuffer::default(), MonologueBuffer::default(), Stance(MovementStance::Sprint), SprintAnomalyQueue::default(), )) .id(); registry.register(player); world.insert_resource(registry); run_observer_pipeline(&mut world); // Anomaly should be queued let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); assert!( queue.has_pending(), "contradicted NPC while sprinting should queue anomaly" ); } #[test] fn walk_past_contradicted_npc_does_not_queue_anomaly() { use crate::simulation::monologue::SprintAnomalyQueue; use crate::simulation::stance::Stance; 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); let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, NearbyInteractionBuffer::default(), MonologueBuffer::default(), Stance(MovementStance::Walk), // Walking, not sprinting SprintAnomalyQueue::default(), )) .id(); registry.register(player); world.insert_resource(registry); run_observer_pipeline(&mut world); let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); assert!( !queue.has_pending(), "walking past contradicted NPC should NOT queue anomaly" ); } #[test] fn sprint_past_active_npc_does_not_queue_anomaly() { use crate::simulation::monologue::SprintAnomalyQueue; use crate::simulation::stance::Stance; 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); // Active knowledge (not contradicted) let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, NearbyInteractionBuffer::default(), MonologueBuffer::default(), Stance(MovementStance::Sprint), SprintAnomalyQueue::default(), )) .id(); registry.register(player); world.insert_resource(registry); run_observer_pipeline(&mut world); let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); assert!( !queue.has_pending(), "sprint past Active NPC should NOT queue anomaly" ); } #[test] fn sprint_anomaly_not_queued_when_already_pending() { use crate::simulation::monologue::SprintAnomalyQueue; use crate::simulation::stance::Stance; 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); let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; // Pre-fill anomaly queue with an existing pending entry let mut queue = SprintAnomalyQueue::default(); queue.push_anomaly(999, 0); // Different entity, already pending let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, NearbyInteractionBuffer::default(), MonologueBuffer::default(), Stance(MovementStance::Sprint), queue, )) .id(); registry.register(player); world.insert_resource(registry); run_observer_pipeline(&mut world); // Queue should still have the original entry (first-in wins) let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); assert!(queue.has_pending()); // The original entity_id should be preserved (not overwritten) // We can't directly inspect the entity_id, but we can verify via take_ready } #[test] fn sprint_anomaly_without_queue_component_no_crash() { use crate::simulation::stance::Stance; // Player without SprintAnomalyQueue should still work (backward compat) 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); let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, NearbyInteractionBuffer::default(), MonologueBuffer::default(), Stance(MovementStance::Sprint), // No SprintAnomalyQueue — should not crash )) .id(); registry.register(player); world.insert_resource(registry); // Should run without panicking run_observer_pipeline(&mut world); let buffer = world.resource::(); assert!( buffer.snapshot.is_some(), "snapshot should still be produced" ); } #[test] fn sprint_anomaly_npc_visible_but_interactions_suppressed() { // D-055: sprint suppresses interpretation, not sensory data. // The NPC should be visible AND queue an anomaly, but interactions empty. use crate::simulation::monologue::SprintAnomalyQueue; use crate::simulation::stance::Stance; let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, )) .id(); let npc_sid = registry.register(npc); let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, NearbyInteractionBuffer::default(), MonologueBuffer::default(), Stance(MovementStance::Sprint), SprintAnomalyQueue::default(), )) .id(); registry.register(player); world.insert_resource(registry); run_full_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); // NPC should be visible (sprint doesn't suppress visibility) let npcs: Vec<_> = snapshot .entities .iter() .filter(|e| matches!(e.kind, EntityKind::Npc)) .collect(); assert_eq!(npcs.len(), 1, "NPC should be visible during sprint"); // Interactions should be empty (sprint suppression) assert!( snapshot.nearby_interactions.is_empty(), "sprint suppresses interactions" ); // Anomaly should be queued let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); assert!( queue.has_pending(), "anomaly should be queued despite interaction suppression" ); } #[test] fn sprint_anomaly_multiple_contradicted_npcs_only_first_queued() { // D-055: first-in wins — only the first Contradicted entity per scan is queued use crate::simulation::monologue::SprintAnomalyQueue; use crate::simulation::stance::Stance; let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // Two contradicted NPCs in LOS let npc1 = world .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) .id(); let npc1_sid = registry.register(npc1); let npc2 = world .spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))) .id(); let npc2_sid = registry.register(npc2); // Player has Contradicted knowledge about BOTH NPCs let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc1_sid, TilePosition::new(16, 14, 0), 50); kg.entities.get_mut(&npc1_sid).unwrap().state = KnowledgeState::Contradicted; kg.observe_entity(npc2_sid, TilePosition::new(16, 12, 0), 50); kg.entities.get_mut(&npc2_sid).unwrap().state = KnowledgeState::Contradicted; let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, NearbyInteractionBuffer::default(), MonologueBuffer::default(), Stance(MovementStance::Sprint), SprintAnomalyQueue::default(), )) .id(); registry.register(player); world.insert_resource(registry); run_observer_pipeline(&mut world); // Exactly one anomaly should be queued (first-in wins, break after first) let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); assert!(queue.has_pending(), "one anomaly should be queued"); } // ----------------------------------------------------------------------- // Pending recognitions in observer snapshot (#423, D-060) // ----------------------------------------------------------------------- #[test] fn pending_recognitions_appear_in_snapshot() { // H11: When a player entity has a CognitiveDelay component with pending // recognitions, compute_observer_snapshot should include them in // pending_recognitions for the client to render as grey blobs. use crate::perception::cognitive_delay::{ CognitiveDelay, PendingRecognition, RecognitionTrigger, }; let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // Target entity that is being "recognized" let target = world.spawn_empty().id(); let target_sid = registry.register(target); // Player with CognitiveDelay containing a pending recognition let mut cd = CognitiveDelay::default(); cd.push(PendingRecognition { target, stable_id: target_sid, position: TilePosition::new(16, 14, 0), delay_until_tick: 110, // will complete at tick 110 trigger: RecognitionTrigger::Normal, monologue_fired: false, }); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), cd, )) .id(); registry.register(player); world.insert_resource(registry); world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 106; // 4 ticks remaining until recognition t }); run_observer_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); assert_eq!( snapshot.pending_recognitions.len(), 1, "should have one pending recognition in snapshot" ); let pending = &snapshot.pending_recognitions[0]; assert_eq!(pending.entity_id, target_sid.0); assert_eq!(pending.remaining_ticks, 4, "110 - 106 = 4 remaining"); assert_eq!( pending.total_delay_ticks, crate::perception::cognitive_delay::NORMAL_DELAY_TICKS, "total delay should match Normal trigger" ); // Position should be render coords of (16, 14, 0) let (expected_x, expected_y, expected_z) = TilePosition::new(16, 14, 0).to_render_coords(); assert_eq!(pending.x, expected_x); assert_eq!(pending.y, expected_y); assert_eq!(pending.z, expected_z); } // ----------------------------------------------------------------------- // Determinism regression tests (#456/#457 — Fix A + Fix B) // ----------------------------------------------------------------------- #[test] fn equidistant_npcs_produce_stable_snapshot_ordering() { // Fix A (#456): visible_ids uses BTreeSet for deterministic iteration. // Fix B (#457): entities sorted by entity_id in snapshot. // Regression guard: equidistant NPCs must always appear in ascending // entity_id order regardless of ECS internal iteration order. let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // Three NPCs equidistant from observer at (16,16) — all within forward cone. // Spawn order: npc_a, npc_b, npc_c → ascending stable_ids. let npc_a = world .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) .id(); let npc_a_sid = registry.register(npc_a); let npc_b = world .spawn((crate::npc::Npc, TilePosition::new(14, 14, 0))) .id(); let npc_b_sid = registry.register(npc_b); let npc_c = world .spawn((crate::npc::Npc, TilePosition::new(18, 14, 0))) .id(); let npc_c_sid = registry.register(npc_c); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), 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 npc_ids: Vec = snapshot .entities .iter() .filter(|e| matches!(e.kind, EntityKind::Npc)) .map(|e| e.entity_id) .collect(); assert_eq!( npc_ids.len(), 3, "all three equidistant NPCs should be visible" ); // Entity IDs must be in strictly ascending order (Fix B sort guarantee) for i in 1..npc_ids.len() { assert!( npc_ids[i - 1] < npc_ids[i], "snapshot entities not sorted by entity_id: {:?}", npc_ids ); } // Verify the ordering matches the expected stable_id assignment order assert_eq!(npc_ids[0], npc_a_sid.0); assert_eq!(npc_ids[1], npc_b_sid.0); assert_eq!(npc_ids[2], npc_c_sid.0); } #[test] fn visible_tiles_sorted_by_coordinates() { // Fix A (#456): visible_tiles sorted by (x, y) for deterministic snapshots. let mut world = setup_world(32, 32); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), 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" ); // All tiles must be sorted by (x, y) for i in 1..snapshot.visible_tiles.len() { let prev = &snapshot.visible_tiles[i - 1]; let curr = &snapshot.visible_tiles[i]; assert!( (prev.x, prev.y) <= (curr.x, curr.y), "visible_tiles not sorted: ({},{}) > ({},{})", prev.x, prev.y, curr.x, curr.y, ); } } #[test] fn no_cognitive_delay_component_means_empty_pending_recognitions() { // H11 complement: player WITHOUT CognitiveDelay should produce // an empty pending_recognitions vec (backward compatibility). 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.pending_recognitions.is_empty(), "no CognitiveDelay component should produce empty pending_recognitions" ); } // ----------------------------------------------------------------------- // blocked_entities debug field tests (#514) // ----------------------------------------------------------------------- #[test] fn blocked_entities_empty_when_all_visible() { 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(); registry.register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), 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(); assert!( snapshot.blocked_entities.is_empty(), "no blocked entities when NPC is in LOS" ); } #[test] fn npc_behind_wall_appears_in_blocked_entities() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // Wall between player and NPC world .resource_mut::() .set_walkable(&TilePosition::new(16, 14, 0), false); // NPC behind the wall let npc = world .spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))) .id(); let npc_sid = registry.register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), 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(); assert!( snapshot.blocked_entities.contains(&npc_sid.0), "NPC behind wall should appear in blocked_entities" ); // Not in visible entities let npc_visible = snapshot.entities.iter().any(|e| e.entity_id == npc_sid.0); assert!(!npc_visible, "NPC should not be in visible entities"); } #[test] fn npc_behind_player_appears_in_blocked_entities() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC far behind player (south, outside vision cone) let npc = world .spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))) .id(); let npc_sid = registry.register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), 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(); assert!( snapshot.blocked_entities.contains(&npc_sid.0), "NPC in blind spot should appear in blocked_entities" ); } #[test] fn different_z_level_not_in_blocked_entities() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC on a different z-level let npc = world .spawn((crate::npc::Npc, TilePosition::new(16, 14, 1))) .id(); let npc_sid = registry.register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default(), KnowledgeGraph::new(), 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(); assert!( !snapshot.blocked_entities.contains(&npc_sid.0), "NPC on different z-level should NOT be in blocked_entities" ); } // ----------------------------------------------------------------------- // Component-level access control tests (#139, D-010 principle 2) // ----------------------------------------------------------------------- #[test] fn access_rule_owner_only_redacts_relationship() { // THE critical negative test for #139: NPC with OwnerOnly access rule // is physically visible (in LOS) but relationship data is redacted. use crate::knowledge::types::AccessRule; let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC with OwnerOnly access rule — only the NPC itself can read its data let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 14, 0), AccessRule(ObserverAccess::OwnerOnly), )) .id(); let npc_sid = registry.register(npc); // Player knows NPC as Hostile — but access should be denied let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); kg.set_relationship(&npc_sid, RelationshipState::Hostile); 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(); // NPC should be visible (physically in LOS) let npc_entity = snapshot .entities .iter() .find(|e| matches!(e.kind, EntityKind::Npc)) .expect("NPC should be visible even with OwnerOnly access"); // But relationship must be redacted to Unknown (access denied) assert_eq!( npc_entity.relationship, RelationshipState::Unknown, "OwnerOnly access should redact relationship to Unknown for non-owner observer" ); assert_eq!(npc_entity.observation, EntityVisibility::Visible); } #[test] fn access_rule_knowledge_gated_passes_with_matching_fact() { // Positive test: observer has the required fact, relationship visible. use crate::knowledge::types::{AccessRule, FactId, FactKnowledge, KnowledgeSource}; let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC gated on a specific fact let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 14, 0), AccessRule(ObserverAccess::KnowledgeGated( "contraband.ring_exists".into(), )), )) .id(); let npc_sid = registry.register(npc); // Player knows the required fact AND has a relationship with the NPC let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); kg.facts.insert( FactId("contraband.ring_exists".into()), FactKnowledge { confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, disclosure_blocked: false, }, ); 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 npc_entity = snapshot .entities .iter() .find(|e| matches!(e.kind, EntityKind::Npc)) .expect("NPC should be visible"); // Observer has the required fact — relationship should be visible assert_eq!( npc_entity.relationship, RelationshipState::PersonOfInterest, "KnowledgeGated access should pass when observer has the required fact" ); } #[test] fn access_rule_knowledge_gated_redacts_without_fact() { // Negative test: observer lacks the required fact, relationship redacted. use crate::knowledge::types::AccessRule; let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC gated on a fact the observer doesn't have let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 14, 0), AccessRule(ObserverAccess::KnowledgeGated( "conspiracy.mastermind".into(), )), )) .id(); let npc_sid = registry.register(npc); // Player has relationship but NOT the required fact let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); kg.set_relationship(&npc_sid, RelationshipState::Hostile); 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 npc_entity = snapshot .entities .iter() .find(|e| matches!(e.kind, EntityKind::Npc)) .expect("NPC should be visible (in LOS)"); assert_eq!( npc_entity.relationship, RelationshipState::Unknown, "KnowledgeGated access should redact relationship when observer lacks the fact" ); } #[test] fn no_access_rule_defaults_to_public() { // Existing behavior: entities without AccessRule are fully visible. // This is a regression guard — existing tests also cover this implicitly. let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // NPC with NO AccessRule component let npc = world .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) .id(); let npc_sid = registry.register(npc); let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); kg.set_relationship(&npc_sid, RelationshipState::Friendly); 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 npc_entity = snapshot .entities .iter() .find(|e| matches!(e.kind, EntityKind::Npc)) .expect("NPC should be visible"); assert_eq!( npc_entity.relationship, RelationshipState::Friendly, "No AccessRule should default to Public — relationship fully visible" ); } #[test] fn blocked_entities_sorted_ascending() { // Multiple blocked NPCs should appear in ascending entity_id order let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // Wall blocks north world .resource_mut::() .set_walkable(&TilePosition::new(16, 14, 0), false); // Two NPCs behind wall + one behind player let npc_a = world .spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))) .id(); let npc_a_sid = registry.register(npc_a); let npc_b = world .spawn((crate::npc::Npc, TilePosition::new(16, 10, 0))) .id(); let npc_b_sid = registry.register(npc_b); let npc_c = world .spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))) .id(); let npc_c_sid = registry.register(npc_c); let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), 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(); assert!(snapshot.blocked_entities.len() >= 3); // Must be sorted ascending (BTreeSet guarantee) for i in 1..snapshot.blocked_entities.len() { assert!( snapshot.blocked_entities[i - 1] < snapshot.blocked_entities[i], "blocked_entities not sorted: {:?}", snapshot.blocked_entities ); } // All three NPCs should be present assert!(snapshot.blocked_entities.contains(&npc_a_sid.0)); assert!(snapshot.blocked_entities.contains(&npc_b_sid.0)); assert!(snapshot.blocked_entities.contains(&npc_c_sid.0)); } // ----------------------------------------------------------------------- // Zone enrichment tests (#523, D-077) // ----------------------------------------------------------------------- #[test] fn zone_map_enriches_visible_tiles_with_zone_id() { // Tile inside a zone should get zone_id = Some(zone_id) use crate::simulation::zone::ZoneMap; let mut world = setup_world(32, 32); let mut zone_map = ZoneMap::default(); // Zone 42 covers (14..18, 14..18) — includes the observer's tile at (16,16) zone_map.set_rect(14, 14, 4, 4, 0, 42); world.insert_resource(zone_map); 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(); // Observer's tile (16, 16) is inside zone 42 let observer_tile = snapshot .visible_tiles .iter() .find(|t| t.x == 16 && t.y == 16 && t.z == 0) .expect("observer tile should be visible"); assert_eq!( observer_tile.zone_id, Some(42), "tile inside zone should have zone_id" ); } #[test] fn zone_map_tiles_outside_zone_get_none() { // Tile outside any zone should get zone_id = None use crate::simulation::zone::ZoneMap; let mut world = setup_world(32, 32); let mut zone_map = ZoneMap::default(); // Zone only covers (0..2, 0..2) — far from observer at (16,16) zone_map.set_rect(0, 0, 2, 2, 0, 7); world.insert_resource(zone_map); 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(); // Observer's tile (16, 16) is outside any zone let observer_tile = snapshot .visible_tiles .iter() .find(|t| t.x == 16 && t.y == 16 && t.z == 0) .expect("observer tile should be visible"); assert_eq!( observer_tile.zone_id, None, "tile outside any zone should have zone_id = None" ); } #[test] fn no_zone_map_resource_tiles_have_no_zone_id() { // When ZoneMap resource is absent, all tiles should have zone_id = None let mut world = setup_world(32, 32); // Do NOT insert ZoneMap resource 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" ); // All tiles should have zone_id = None when no ZoneMap exists for tile in &snapshot.visible_tiles { assert_eq!( tile.zone_id, None, "tile ({},{}) should have zone_id = None without ZoneMap resource", tile.x, tile.y ); } } // --------------------------------------------------------------------------- // #337 — Tell state → snapshot integration (D-024 tell system) // --------------------------------------------------------------------------- /// Helper: run derive_tell_state + two-stage observer pipeline together. fn run_tell_plus_observer_pipeline(world: &mut World) { use crate::npc::tell_state::derive_tell_state; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(( derive_tell_state, compute_visibility_geometry.after(derive_tell_state), compute_observer_snapshot .after(compute_visibility_geometry) .after(derive_tell_state), )); schedule.run(world); } #[test] fn tell_state_nervous_appears_in_snapshot_for_major_secret_high_stress() { // Spec (#337, D-024): NPC with Major secret + stress past midpoint shows // TellCategory::Nervous in ObserverSnapshot.entities[].tell_state. // End-to-end pipeline: axis values → derive_tell_state → DerivedTellState // → compute_observer_snapshot → VisibleEntity.tell_state. use crate::npc::mood::{MoodState, NpcMood}; use crate::npc::tell_state::{DerivedTellState, TellCategory}; use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold}; use crate::simulation::tier::ActiveSim; 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 — in forward LOS — with Major secret + stress > midpoint. // stress=60, threshold=100 → stress*2=120 > 100 → Nervous (D-024 priority 2) world.spawn(( Npc, ActiveSim, TilePosition::new(16, 14, 0), Secret { description: "criminal record".into(), severity: SecretSeverity::Major, known_by: vec![], }, ToleranceThreshold { current_stress: 60, threshold: 100, }, Contentment { level: 0 }, MoodState { mood: NpcMood::Neutral, changed_tick: 0, }, DerivedTellState::default(), )); run_tell_plus_observer_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); let npc = snapshot .entities .iter() .find(|e| matches!(e.kind, EntityKind::Npc)) .expect("NPC should be visible in snapshot"); assert_eq!( npc.tell_state, Some(TellCategory::Nervous), "NPC with Major secret + stress past midpoint should show Nervous tell in snapshot" ); } #[test] fn tell_state_none_for_neutral_npc_in_snapshot() { // Spec (#337, D-024): neutral NPC shows tell_state = None in snapshot. // Verifies the pipeline correctly omits tell when no conditions are met. use crate::npc::mood::{MoodState, NpcMood}; use crate::npc::tell_state::DerivedTellState; use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold}; use crate::simulation::tier::ActiveSim; 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 in LOS — neutral state (Minor secret, low stress, neutral mood, low contentment) world.spawn(( Npc, ActiveSim, TilePosition::new(16, 14, 0), Secret { description: "minor embarrassment".into(), severity: SecretSeverity::Minor, known_by: vec![], }, ToleranceThreshold { current_stress: 10, threshold: 100, }, Contentment { level: 0 }, MoodState { mood: NpcMood::Neutral, changed_tick: 0, }, DerivedTellState::default(), )); run_tell_plus_observer_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); let npc = snapshot .entities .iter() .find(|e| matches!(e.kind, EntityKind::Npc)) .expect("NPC should be visible in snapshot"); assert_eq!( npc.tell_state, None, "neutral NPC should have no tell state in snapshot" ); } #[test] fn tell_state_none_when_npc_has_no_derived_tell_component() { // Spec (#337): NPC without DerivedTellState component has tell_state = None. // Verifies Option<&DerivedTellState> query handles absent component gracefully. 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 with no DerivedTellState component at all 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().expect("snapshot should exist"); let npc = snapshot .entities .iter() .find(|e| matches!(e.kind, EntityKind::Npc)) .expect("NPC should be visible in snapshot"); assert_eq!( npc.tell_state, None, "NPC without DerivedTellState component should have tell_state = None" ); } // ----------------------------------------------------------------------- // Regression: Phase 2 container verb labels are uniform (D-057 / #878) // ----------------------------------------------------------------------- #[test] fn phase2_container_verb_labels_uniform_regardless_of_player_state() { // Regression guard (D-057 superseded, #878 cascade purge): // apply_phase2_verb_filter no longer has an archetype branch that relabels // container verbs. Labels must be the Phase-1 defaults — "Open", "Search", // "Observe" — regardless of the observer's KnowledgeGraph contents or // relationship state with other entities. // // This test FAILS if a character-class verb-label branch is reintroduced // without a confirmed Phase 6 scope decision from the team lead. // --- Trial A: empty KnowledgeGraph --- { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let container = world .spawn(( TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, ObjectType::Container, )) .id(); registry.register(container); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), )); world.insert_resource(registry); run_full_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); let labels: Vec<&str> = snapshot.nearby_interactions[0] .verbs .iter() .map(|v| v.label.as_str()) .collect(); assert_eq!( labels, ["Open", "Search", "Observe"], "Trial A (empty KG): container verb labels must equal Phase-1 defaults" ); } // --- Trial B: KG with PersonOfInterest NPC nearby --- // Player has a non-trivial knowledge state; container labels must still be // the Phase-1 defaults — Phase 2 NPC-specific logic must not bleed into // ObjectType::Container interactions. { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); let container = world .spawn(( TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, ObjectType::Container, )) .id(); registry.register(container); // NPC out of close-range so Confront is not injected; still in KG as POI. let npc = world .spawn(( crate::npc::Npc, TilePosition::new(16, 13, 0), crate::simulation::interaction::Interactable, )) .id(); let npc_sid = registry.register(npc); let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(16, 13, 0), 5); kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), kg, NearbyInteractionBuffer::default(), MonologueBuffer::default(), )); world.insert_resource(registry); run_full_pipeline(&mut world); let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); let container_interaction = snapshot .nearby_interactions .iter() .find(|i| i.object_type == Some(ObjectType::Container)) .expect("container interaction must be present"); let labels: Vec<&str> = container_interaction .verbs .iter() .map(|v| v.label.as_str()) .collect(); assert_eq!( labels, ["Open", "Search", "Observe"], "Trial B (POI NPC in KG): container verb labels must equal Phase-1 defaults" ); } }