//! Integration tests for basic environmental interaction (#246). //! //! Covers the acceptance criteria from the sprint briefing: //! - Door toggle: player interacts with a Door, walkability flips; interacts again, flips back. //! - Readable examine: Examine on a Readable entity returns non-empty text. //! - Terminal interaction: Use on a Terminal emits TerminalInteracted event. //! - DoorState persists in SaveStateV1.open_doors. use bevy_ecs::{prelude::*, schedule::Schedule, world::World}; use settled_reach_server::knowledge::KnowledgeGraph; use settled_reach_server::simulation::triangle::TemplateReferenceMap; use settled_reach_server::{ knowledge::{registry::EntityRegistry, registry::StableEntityId, types::StableId}, npc::relationships::RelationshipGraph, simulation::{ examine::{process_examine_interaction, ExamineRequest, ExamineResultBuffer, ExamineText}, interaction::{ process_door_interaction, process_terminal_interaction, DoorInteractRequest, DoorState, Interactable, ObjectType, TerminalInteractRequest, TerminalInteractedQueue, }, movement::{PlayerCharacter, TilePosition, WalkabilityMap}, save_state::{SaveStateV1, SAVE_FORMAT_VERSION}, time::{SimulationTime, TickRate}, }, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn make_world_with_walkability(width: i32, height: i32) -> World { let mut world = World::new(); world.insert_resource(WalkabilityMap::new(width, height, 1)); world.init_resource::(); world } fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity { world .spawn((PlayerCharacter, TilePosition::new(x, y, 0))) .id() } fn spawn_door(world: &mut World, x: i32, y: i32, blocking_x: i32, blocking_y: i32) -> Entity { world .spawn(( TilePosition::new(x, y, 0), Interactable, ObjectType::Door, DoorState::new(TilePosition::new(blocking_x, blocking_y, 0)), )) .id() } // --------------------------------------------------------------------------- // Door behavior tests // --------------------------------------------------------------------------- /// Acceptance: player interacts with Door, walkability flips; interacts again, flips back. #[test] fn door_toggle_flips_walkability_both_ways() { let mut world = make_world_with_walkability(20, 20); // Block the door tile initially world .resource_mut::() .set_walkable(&TilePosition::new(10, 5, 0), false); let player = spawn_player(&mut world, 10, 6); let door = spawn_door(&mut world, 10, 6, 10, 5); // door tile at (10,5) let mut schedule = Schedule::default(); schedule.add_systems(process_door_interaction); // First interaction: open the door world .entity_mut(player) .insert(DoorInteractRequest { door_entity: door }); schedule.run(&mut world); assert!( world .resource::() .can_move_to(&TilePosition::new(10, 5, 0)), "after Open: blocking tile must become walkable" ); assert!( world.get::(door).unwrap().is_open, "DoorState.is_open must be true after opening" ); // Request should be consumed assert!( world.get::(player).is_none(), "DoorInteractRequest must be removed after processing" ); // Second interaction: close the door world .entity_mut(player) .insert(DoorInteractRequest { door_entity: door }); schedule.run(&mut world); assert!( !world .resource::() .can_move_to(&TilePosition::new(10, 5, 0)), "after Close: blocking tile must be impassable again" ); assert!( !world.get::(door).unwrap().is_open, "DoorState.is_open must be false after closing" ); } /// Door starts open: toggling closes it (walkability → blocked). #[test] fn door_starts_open_toggle_closes_it() { let mut world = make_world_with_walkability(20, 20); // Start with door open (tile walkable, is_open = true) let player = spawn_player(&mut world, 10, 6); let door = world .spawn(( TilePosition::new(10, 6, 0), Interactable, ObjectType::Door, DoorState { is_open: true, blocking_tile: TilePosition::new(10, 5, 0), }, )) .id(); // Tile starts walkable (default map is all walkable) assert!( world .resource::() .can_move_to(&TilePosition::new(10, 5, 0)), "precondition: tile is walkable when door starts open" ); let mut schedule = Schedule::default(); schedule.add_systems(process_door_interaction); world .entity_mut(player) .insert(DoorInteractRequest { door_entity: door }); schedule.run(&mut world); assert!( !world .resource::() .can_move_to(&TilePosition::new(10, 5, 0)), "toggling an open door must block the tile" ); assert!( !world.get::(door).unwrap().is_open, "DoorState.is_open must be false after closing an open door" ); } /// Missing DoorState on target: system logs warning and removes request without panic. #[test] fn door_interact_without_door_state_does_not_panic() { let mut world = make_world_with_walkability(10, 10); let player = spawn_player(&mut world, 5, 5); let not_a_door = world.spawn(TilePosition::new(5, 6, 0)).id(); world.entity_mut(player).insert(DoorInteractRequest { door_entity: not_a_door, }); let mut schedule = Schedule::default(); schedule.add_systems(process_door_interaction); schedule.run(&mut world); // Should not panic; request removed assert!( world.get::(player).is_none(), "DoorInteractRequest must be consumed even when target lacks DoorState" ); } // --------------------------------------------------------------------------- // Readable examine tests // --------------------------------------------------------------------------- /// Acceptance: Examine on a Readable entity with ExamineText returns non-empty text. #[test] fn examine_readable_returns_authored_text() { use settled_reach_server::knowledge::events::KnowledgeEventQueue; let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), ExamineResultBuffer::default(), )) .id(); let readable = world .spawn(( TilePosition::new(5, 6, 0), Interactable, ObjectType::Readable, ExamineText( "A logistics manifest. Freight records dating back three cycles.".to_string(), ), )) .id(); world .entity_mut(player) .insert(ExamineRequest { target: readable }); let mut schedule = Schedule::default(); schedule.add_systems(process_examine_interaction); schedule.run(&mut world); let event = world.get_mut::(player).unwrap().take(); assert!( event.is_some(), "ExamineResultBuffer must contain a result after examining a Readable" ); let text = event.unwrap().text; assert!( !text.is_empty(), "examine result text must be non-empty for a Readable entity" ); assert!( text.contains("manifest") || text.contains("Freight") || text.contains("records"), "text should match the authored ExamineText, got: '{text}'" ); } /// Examine on a Readable entity WITHOUT ExamineText returns a generic non-empty fallback. #[test] fn examine_readable_without_examine_text_returns_fallback() { use settled_reach_server::knowledge::events::KnowledgeEventQueue; let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), ExamineResultBuffer::default(), )) .id(); // Readable but no ExamineText component let readable = world .spawn(( TilePosition::new(5, 6, 0), Interactable, ObjectType::Readable, )) .id(); world .entity_mut(player) .insert(ExamineRequest { target: readable }); let mut schedule = Schedule::default(); schedule.add_systems(process_examine_interaction); schedule.run(&mut world); let event = world.get_mut::(player).unwrap().take(); assert!( event.is_some(), "ExamineResultBuffer must contain a result even without ExamineText" ); let text = event.unwrap().text; assert!( !text.is_empty(), "fallback text must be non-empty, got: '{text}'" ); } /// Examine out of range returns no result. #[test] fn examine_readable_out_of_range_returns_no_result() { use settled_reach_server::knowledge::events::KnowledgeEventQueue; let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), ExamineResultBuffer::default(), )) .id(); // Distance 8 > CLOSE_RANGE (2) let readable = world .spawn(( TilePosition::new(5, 13, 0), Interactable, ObjectType::Readable, ExamineText("Out of range text".to_string()), )) .id(); world .entity_mut(player) .insert(ExamineRequest { target: readable }); let mut schedule = Schedule::default(); schedule.add_systems(process_examine_interaction); schedule.run(&mut world); let event = world.get_mut::(player).unwrap().take(); assert!( event.is_none(), "examining an out-of-range Readable must not produce a result" ); } // --------------------------------------------------------------------------- // Terminal interaction tests // --------------------------------------------------------------------------- /// Use on a Terminal emits TerminalInteracted event. #[test] fn terminal_use_emits_terminal_interacted_event() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let terminal = world .spawn(( TilePosition::new(5, 6, 0), Interactable, ObjectType::Terminal, )) .id(); // Register terminal in EntityRegistry so stable ID resolves let terminal_sid = StableId(42); world .entity_mut(terminal) .insert(StableEntityId(terminal_sid)); world .resource_mut::() .register_existing(terminal, terminal_sid); world.entity_mut(player).insert(TerminalInteractRequest { terminal_entity: terminal, }); let mut schedule = Schedule::default(); schedule.add_systems(process_terminal_interaction); schedule.run(&mut world); let queue = world.resource::(); assert_eq!( queue.events.len(), 1, "one TerminalInteracted event must be emitted" ); assert_eq!( queue.events[0].terminal_id, terminal_sid, "terminal_id must match the interacted terminal" ); } /// TerminalInteractRequest is removed after processing. #[test] fn terminal_interact_request_consumed_after_processing() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let terminal = world.spawn(TilePosition::new(5, 6, 0)).id(); world.entity_mut(player).insert(TerminalInteractRequest { terminal_entity: terminal, }); let mut schedule = Schedule::default(); schedule.add_systems(process_terminal_interaction); schedule.run(&mut world); assert!( world.get::(player).is_none(), "TerminalInteractRequest must be removed after processing" ); } // --------------------------------------------------------------------------- // SaveStateV1.open_doors field // --------------------------------------------------------------------------- fn minimal_save() -> SaveStateV1 { SaveStateV1 { format_version: SAVE_FORMAT_VERSION, tick: 0, tick_rate: TickRate::Full, seed: 42, player_knowledge: KnowledgeGraph::new(), relationship_graph: RelationshipGraph::new(), npc_states: vec![], template_references: TemplateReferenceMap::default(), triangle_states: vec![], open_doors: vec![], modifications: vec![], contamination_active: false, activated_count: 0, last_activation_tick: None, selected_bookmark: settled_reach_server::bookmark::SelectedBookmark::default(), } } /// SaveStateV1.open_doors round-trips through MessagePack serialization. #[test] fn save_state_open_doors_roundtrip() { let mut save = minimal_save(); save.open_doors = vec![StableId(10), StableId(20), StableId(30)]; let bytes = save.to_bytes().expect("serialize"); let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); assert_eq!( recovered.open_doors, vec![StableId(10), StableId(20), StableId(30)], "open_doors must round-trip through MessagePack" ); } /// Older save files (no open_doors field) deserialize without error. /// The field defaults to empty vec via #[serde(default)]. #[test] fn save_state_open_doors_defaults_to_empty_on_old_saves() { let save = minimal_save(); assert!( save.open_doors.is_empty(), "open_doors must default to empty vec (backward compat with older saves)" ); }