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" ); } // ----------------------------------------------------------------------- // 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, AND Confront (Phase 2 injected) assert_eq!(interaction.verbs.len(), 3); 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(), }); 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_smuggler_relabels_container_verbs() { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); // Container at close range, north of player let container = world .spawn(( TilePosition::new(16, 15, 0), crate::simulation::interaction::Interactable, ObjectType::Container, )) .id(); registry.register(container); // Smuggler player let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), CharacterArchetype::Smuggler, )) .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]; // Container at close range: Open→"Move", Search→"Stash", Observe stays "Observe" let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); let search_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Search); let observe_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Observe); assert_eq!(open_verb.unwrap().label, "Move", "smuggler Open→Move"); assert_eq!(search_verb.unwrap().label, "Stash", "smuggler Search→Stash"); assert_eq!(observe_verb.unwrap().label, "Observe", "Observe unchanged"); } #[test] fn phase2_detective_relabels_container_verbs() { 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); // Detective player (explicit) let player = world .spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), Facing(FacingDirection::North), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueBuffer::default(), CharacterArchetype::Detective, )) .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 open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); let search_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Search); assert_eq!(open_verb.unwrap().label, "Scan", "detective Open→Scan"); assert_eq!(search_verb.unwrap().label, "Flag", "detective Search→Flag"); } #[test] fn phase2_default_archetype_is_detective() { // When no CharacterArchetype component attached, defaults to Detective 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); // Player WITHOUT CharacterArchetype component 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]; // Default = Detective labels let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); assert_eq!(open_verb.unwrap().label, "Scan", "default archetype should use Detective labels"); } #[test] fn phase2_non_container_keeps_default_labels() { // Readable objects should keep their default labels regardless of archetype 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(), CharacterArchetype::Smuggler, )) .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 for smuggler"); } #[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(), 3, "POI+KnowsDetails: ExamineNpc + Talk + Confront"); // POI flips ExamineNpc to priority 1, Talk to 2, 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); assert_eq!(verbs[2].kind, VerbKind::Confront); assert_eq!(verbs[2].priority, 3); } // ----------------------------------------------------------------------- // 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"); }