diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index 14ceb5967..a84f1b351 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -684,3 +684,1043 @@ fn snapshot_v6_version_is_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"); +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index b0a8cd6cb..b3a9fee05 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -819,4 +819,105 @@ mod tests { assert!(world.get::(item).is_some(), "item stays on ground"); assert!(world.get::(item).is_none(), "no CarriedBy when full"); } + + #[test] + fn take_then_place_roundtrip() { + // D-065: full cycle — item on ground → Take → carried → Place → ground again + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + let item = world + .spawn(( + TilePosition::new(5, 4, 0), + ItemName("Manifest Copy".into()), + )) + .id(); + let item_sid = world.resource_mut::().register(item); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // Step 1: Take + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Take".into()), + }, + }); + schedule.run(&mut world); + + assert!(world.get::(item).is_none(), "item off ground after Take"); + assert_eq!(world.get::(item).unwrap().0, player_sid); + assert_eq!(world.get::(item).unwrap().0, 0); + + // Step 2: Place + world.resource_mut::().push(PlayerInput { + tick: 1, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Place".into()), + }, + }); + world.resource_mut::().tick = 1; + schedule.run(&mut world); + + let pos = world.get::(item).expect("item back on ground after Place"); + assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position"); + assert!(world.get::(item).is_none(), "CarriedBy removed after Place"); + assert!(world.get::(item).is_none(), "InventorySlot removed after Place"); + } + + #[test] + fn take_without_target_id_is_noop() { + // Edge case: Take verb with no target_entity_id should not panic + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: None, + verb: Some("Take".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); // should not panic + } + + #[test] + fn place_without_target_id_is_noop() { + // Edge case: Place verb with no target_entity_id should not panic + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: None, + verb: Some("Place".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); // should not panic + } } diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index 368121cbf..13ba4a7a6 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -857,4 +857,50 @@ mod tests { "two Fixtures on same tile should collide" ); } + + #[test] + fn all_four_layers_coexist_on_same_tile() { + // D-054: Standing + Prone + Seated + Fixture all share one tile + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let target = TilePosition::new(5, 4, 0); + + // Fixture and Prone already at tile + world.spawn((target, TilePresence::Fixture)); + world.spawn((target, TilePresence::Prone)); + + // Standing mover enters + let standing = world + .spawn(( + TilePosition::new(5, 5, 0), + TilePresence::Standing, + MoveIntent { target }, + )) + .id(); + + // Seated mover enters from elsewhere + let seated = world + .spawn(( + TilePosition::new(5, 3, 0), + TilePresence::Seated, + MoveIntent { target }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert_eq!( + *world.get::(standing).unwrap(), + target, + "Standing should share tile with Fixture + Prone" + ); + assert_eq!( + *world.get::(seated).unwrap(), + target, + "Seated should share tile with Fixture + Prone + Standing" + ); + } } diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 70acfe4c7..d392f17cb 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -506,3 +506,96 @@ fn all_object_type_variants_roundtrip() { assert_eq!(decoded, obj_type, "ObjectType::{:?} roundtrip failed", obj_type); } } + +/// VerbKind::Confront (Phase 2, #422) must survive MessagePack round-trip. +/// Guards against Confront being omitted from serde mapping. +#[test] +fn verb_kind_confront_roundtrip() { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.nearby_interactions = vec![NearbyInteraction { + entity_id: 1, + entity_type: EntityKind::Npc, + distance: 1, + verbs: vec![VerbOption { + kind: VerbKind::Confront, + label: "Confront".into(), + priority: 3, + available: true, + }], + object_type: None, + contradicted: false, + }]; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.nearby_interactions.len(), 1); + assert_eq!(decoded.nearby_interactions[0].verbs[0].kind, VerbKind::Confront); + assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront"); +} + +/// CharacterArchetype enum round-trips through MessagePack (#422). +/// Used in Phase 2 label relabeling — must survive the wire. +#[test] +fn all_character_archetype_variants_roundtrip() { + let archetypes = [ + CharacterArchetype::Smuggler, + CharacterArchetype::Detective, + ]; + + for archetype in archetypes { + let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize"); + let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded, archetype, "CharacterArchetype::{:?} roundtrip failed", archetype); + } +} + +/// NearbyInteraction.contradicted=true round-trips through MessagePack (#422). +/// Guards the contradiction flag survives serialization. +#[test] +fn nearby_interaction_contradicted_roundtrip() { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.nearby_interactions = vec![NearbyInteraction { + entity_id: 1, + entity_type: EntityKind::Npc, + distance: 1, + verbs: vec![VerbOption { + kind: VerbKind::Talk, + label: "Talk".into(), + priority: 1, + available: true, + }], + object_type: None, + contradicted: true, + }]; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert!(decoded.nearby_interactions[0].contradicted, "contradicted flag should survive roundtrip"); +} + +/// NearbyInteraction.object_type round-trips through MessagePack (#422). +/// Verifies object_type=Some(Container) survives the wire. +#[test] +fn nearby_interaction_object_type_roundtrip() { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.nearby_interactions = vec![NearbyInteraction { + entity_id: 1, + entity_type: EntityKind::Object, + distance: 1, + verbs: vec![VerbOption { + kind: VerbKind::Open, + label: "Open".into(), + priority: 1, + available: true, + }], + object_type: Some(ObjectType::Container), + contradicted: false, + }]; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.nearby_interactions[0].object_type, Some(ObjectType::Container)); +}