//! Cross-room transition test scenarios (T1-T8) //! //! Sprint 11 (#506) — system-combination tests at room boundaries. //! Each test exercises a bug class that emerges when two subsystems interact //! across a coordinate boundary (simulated by player position change). //! //! Tests use the ECS world setup pattern with direct system schedule execution //! — no ad-hoc test harness per sprint requirement. //! //! Decision refs: //! D-055 — sprint suppresses interaction buffer (T1, T8) //! D-065 — 9-slot inventory, CarriedBy component (T2) //! D-031 — pause/unpause, TickRate guard (T3) //! D-041 — KnowledgeGraph persistence across transitions (T4, T5) //! D-060 — cognitive delay, entity recognition persistence (T5) //! D-071 — ListeningFocus eavesdrop positioning (T6, T8) //! D-070 — confrontation as cognitive vulnerability, verb range (T7) //! D-057 — verb computation, interaction range transitions (T7) use bevy_ecs::prelude::*; use bevy_ecs::schedule::Schedule; use settled_reach_server::bridge::types::{MovementStance, VerbKind}; use settled_reach_server::knowledge::registry::EntityRegistry; use settled_reach_server::knowledge::KnowledgeGraph; use settled_reach_server::npc::Npc; use settled_reach_server::simulation::interaction::{ compute_nearby_interactions, Interactable, NearbyInteractionBuffer, ObjectType, }; use settled_reach_server::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; use settled_reach_server::simulation::listening::{ update_listening_focus, ListeningFocus, EAVESDROP_THRESHOLD, EAVESDROP_THRESHOLD_CAREFUL, }; use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use settled_reach_server::simulation::stance::Stance; // --------------------------------------------------------- // Shared helpers // --------------------------------------------------------- /// Minimal world with EntityRegistry (no App — single-system schedule tests). fn setup_world() -> World { let mut world = World::new(); world.init_resource::(); world } fn run_interaction_system(world: &mut World) { let mut sched = Schedule::default(); sched.add_systems(compute_nearby_interactions); sched.run(world); } fn run_listening_system(world: &mut World) { let mut sched = Schedule::default(); sched.add_systems(update_listening_focus); sched.run(world); } // --------------------------------------------------------- // T1: Sprint Exit — buffer clears during sprint, restores on Walk // D-055, Sprint Gauntlet room // --------------------------------------------------------- /// T1 — Sprint Exit. /// /// Player at (4, 12 absolute) — standalone scenario position south of the /// Sprint Gauntlet observer (4, 10 per constants.rs). A Readable sign at /// (6, 12) is within CLOSE_RANGE (distance 2). /// /// During Walk: sign appears in interaction buffer. /// During Sprint: buffer is empty (D-055 suppression). /// After stance returns to Walk: buffer repopulates within one compute cycle. /// /// This covers the cross-room exit behaviour: player sprinting out of the /// Sprint Gauntlet loses all interaction context while in sprint. #[test] fn t1_sprint_suppresses_buffer_and_restores_on_walk() { let mut world = setup_world(); // Player at Sprint Gauntlet observer absolute position (ORIGIN_X=0, ORIGIN_Y=2, // rel observer (4,10) → abs (4,12)), Walk stance. let player = world .spawn(( PlayerCharacter, TilePosition::new(4, 12, 0), NearbyInteractionBuffer::default(), Stance(MovementStance::Walk), )) .id(); // Readable sign at (6, 12) abs — distance 2 from player (CLOSE_RANGE=2). // Mirrors sprint_gauntlet.rs sign entity (StableId 56). let sign = world .spawn(( TilePosition::new(6, 12, 0), Interactable, ObjectType::Readable, )) .id(); world.resource_mut::().register(sign); // --- Walk: sign appears in buffer --- run_interaction_system(&mut world); let interactions = world .get_mut::(player) .unwrap() .take(); assert!( !interactions.is_empty(), "T1 Walk: sign at distance 2 must appear in interaction buffer" ); // --- Sprint: buffer suppressed (D-055) --- world.get_mut::(player).unwrap().0 = MovementStance::Sprint; run_interaction_system(&mut world); let interactions = world .get_mut::(player) .unwrap() .take(); assert!( interactions.is_empty(), "T1 Sprint: interaction buffer must be empty (D-055 sprint suppression)" ); // --- Walk again: buffer repopulates within one compute cycle --- world.get_mut::(player).unwrap().0 = MovementStance::Walk; run_interaction_system(&mut world); let interactions = world .get_mut::(player) .unwrap() .take(); assert!( !interactions.is_empty(), "T1 Walk after Sprint: interaction buffer must repopulate" ); } // --------------------------------------------------------- // T2: Inventory Carry — CarriedBy survives room transition // D-065, Inventory Warehouse → Crowd Plaza // --------------------------------------------------------- /// T2 — Inventory Interact. /// /// Player picks up an item (CarriedBy set, TilePosition removed). /// Player moves to a new coordinate region (simulates room transition). /// /// Asserts: CarriedBy still references player, item has no TilePosition. /// The information boundary (D-010 principle 2) holds across coordinates: /// a carried item is never "in" the new room until explicitly placed. #[test] fn t2_carried_item_survives_room_transition() { let mut world = setup_world(); // Player at Inventory Warehouse observer position (abs 17, 54). let player = world .spawn((PlayerCharacter, TilePosition::new(17, 54, 0))) .id(); let player_sid = world.resource_mut::().register(player); // Item already in inventory (no TilePosition — it has been taken). let item = world .spawn(( CarriedBy(player_sid), ItemName("Manifest Copy".into()), InventorySlot(0), )) .id(); world.resource_mut::().register(item); // Pre-transition invariants. assert!( world.get::(item).is_none(), "T2 pre: carried item must not have TilePosition" ); assert_eq!( world.get::(item).unwrap().0, player_sid, "T2 pre: CarriedBy must reference player" ); // Simulate room transition: player moves to Crowd Plaza observer position. *world.get_mut::(player).unwrap() = TilePosition::new(96, 94, 0); // Post-transition: item state is unchanged by the player's position change. assert!( world.get::(item).is_none(), "T2 post: item must still have no TilePosition (still carried)" ); assert_eq!( world.get::(item).unwrap().0, player_sid, "T2 post: CarriedBy must still reference player after movement" ); assert_eq!( world.get::(item).unwrap().0, 0, "T2 post: InventorySlot must be unchanged after room transition" ); } // --------------------------------------------------------- // T3: Pause Anywhere — mid-corridor pause discards movement // D-031 // --------------------------------------------------------- /// T3 — Pause Anywhere. /// /// Player at a corridor position between two rooms (corridor-N midpoint /// between Hub and Fog Theater, ~abs (50, 39)). /// Pause → movement discarded. Unpause → movement accepted. /// /// Tests that pause state is position-agnostic: the pause guard fires /// regardless of whether the player is inside a room or between rooms. #[test] fn t3_pause_mid_corridor_discards_movement_and_resumes() { use bevy_app::prelude::*; use settled_reach_server::bridge::types::{PlayerAction, PlayerInput}; use settled_reach_server::simulation::input::InputQueue; use settled_reach_server::simulation::time::{SimulationTime, TickRate}; use settled_reach_server::simulation::SimulationPlugin; let mut app = App::new(); app.add_plugins(SimulationPlugin); // 200×200 walkability map covers the full gauntlet coordinate space. app.insert_resource(WalkabilityMap::new(200, 200, 1)); // Player at corridor-N midpoint (between Hub at y≈46 and Fog Theater at y≈2). let player = app .world_mut() .spawn((PlayerCharacter, TilePosition::new(50, 39, 0))) .id(); // --- Step 1: Pause (tick 0) --- app.world_mut() .resource_mut::() .push(PlayerInput { tick: 0, action: PlayerAction::Pause, }); app.update(); assert_eq!( app.world().resource::().tick_rate, TickRate::Paused, "T3 step 1: game must be paused" ); // Paused — advance_tick does not fire; tick stays at 0. assert_eq!(app.world().resource::().tick, 0); // --- Step 2: MoveNorth while paused (tick 0) — must be discarded --- app.world_mut() .resource_mut::() .push(PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }); app.update(); assert_eq!( *app.world().get::(player).unwrap(), TilePosition::new(50, 39, 0), "T3 step 2: player position must be unchanged while paused" ); // --- Step 3: Unpause (tick 0) --- app.world_mut() .resource_mut::() .push(PlayerInput { tick: 0, action: PlayerAction::Unpause, }); app.update(); assert_eq!( app.world().resource::().tick_rate, TickRate::Full, "T3 step 3: game must be running after Unpause" ); // advance_tick fires for the first time (Full rate): tick 0 → 1. assert_eq!(app.world().resource::().tick, 1); // --- Step 4: MoveNorth after unpause (tick 1) — must be accepted --- app.world_mut() .resource_mut::() .push(PlayerInput { tick: 1, action: PlayerAction::MoveNorth, }); app.update(); assert_eq!( *app.world().get::(player).unwrap(), TilePosition::new(50, 38, 0), "T3 step 4: player must move north (y-1) after unpause" ); } // --------------------------------------------------------- // T4: Knowledge Graph Persistence across room transition // D-041 // --------------------------------------------------------- /// T4 — Knowledge State Persistence. /// /// Player observes NPC in Dialogue Room (adds KG entry at Direct confidence). /// Player moves to Hub (simulates room transition). /// /// Asserts: KG entry persists. The KnowledgeGraph component is not cleared /// or invalidated by a change in player TilePosition. #[test] fn t4_knowledge_graph_survives_room_transition() { let mut world = setup_world(); let player = world .spawn(( PlayerCharacter, TilePosition::new(50, 114, 0), // Dialogue Room observer position KnowledgeGraph::new(), )) .id(); // NPC in Dialogue Room (npc_stranger at abs ~(40, 112)). let npc = world.spawn(TilePosition::new(40, 112, 0)).id(); let npc_sid = world.resource_mut::().register(npc); // Player observes NPC — adds KG entry at Direct confidence. world .get_mut::(player) .unwrap() .observe_entity(npc_sid, TilePosition::new(40, 112, 0), 0); assert!( world .get::(player) .unwrap() .knows_entity(&npc_sid), "T4 pre: player must know NPC before room transition" ); // Simulate room transition: player moves to Hub observer position. *world.get_mut::(player).unwrap() = TilePosition::new(50, 58, 0); assert!( world .get::(player) .unwrap() .knows_entity(&npc_sid), "T4 post: KG entry must persist after player moves to Hub" ); assert_eq!( world.get::(player).unwrap().entity_count(), 1, "T4 post: exactly 1 KG entry after room transition" ); } // --------------------------------------------------------- // T5: Entity Knowledge Downgrades on LOS Exit (Fog Carry-Over) // D-041, D-060 // --------------------------------------------------------- /// T5 — Fog Carry-Over. /// /// Player observes NPC in Fog Theater at Direct confidence. /// Player moves to Hub (NPC now out of LOS). Confidence downgrades /// from Direct to KnowsDetails. /// /// Asserts: KG entry persists (entity is remembered, not erased). /// Server-side "fog carry-over" means previously-seen entities remain in /// the KG at reduced confidence so the client can render a "last seen" /// fog state rather than a clean erasure. /// /// NOTE: Uses direct KG API calls (observe_entity, observe_entity_leaving_los) /// rather than running the full perception system. This isolates the KG /// persistence contract from perception scheduling. #[test] fn t5_entity_knowledge_downgrades_on_los_exit_not_erased() { use settled_reach_server::knowledge::types::KnowledgeConfidence; let mut world = setup_world(); let player = world .spawn(( PlayerCharacter, TilePosition::new(56, 18, 0), // Fog Theater observer position KnowledgeGraph::new(), )) .id(); // NPC in Fog Theater (npc_fog_near at abs (38, 16)). let npc = world.spawn(TilePosition::new(38, 16, 0)).id(); let npc_sid = world.resource_mut::().register(npc); // Player observes NPC — Direct confidence. world .get_mut::(player) .unwrap() .observe_entity(npc_sid, TilePosition::new(38, 16, 0), 0); assert_eq!( world .get::(player) .unwrap() .confidence_of(&npc_sid), Some(KnowledgeConfidence::Direct), "T5 pre: NPC must be at Direct confidence while player is in Fog Theater" ); // Player moves to Hub — NPC is now out of LOS. *world.get_mut::(player).unwrap() = TilePosition::new(50, 58, 0); // Observation system downgrades confidence (entity left LOS). world .get_mut::(player) .unwrap() .observe_entity_leaving_los(&npc_sid, 1); let kg = world.get::(player).unwrap(); assert!( kg.knows_entity(&npc_sid), "T5 post: KG entry must persist after player leaves the room (fog carry-over)" ); assert_eq!( kg.confidence_of(&npc_sid), Some(KnowledgeConfidence::KnowsDetails), "T5 post: confidence must downgrade from Direct to KnowsDetails on LOS exit" ); } // --------------------------------------------------------- // T6: Eavesdrop Cut on Player Movement // D-071, Eavesdrop Alcove // --------------------------------------------------------- /// T6 — Eavesdrop Cut on Transition. /// /// Player is stationary at the Eavesdrop Alcove corner position /// (abs 78, 36) with an active eavesdrop_target. Player moves one step /// south (leaving the eavesdrop position). Asserts: stationary_ticks resets /// to 0 and eavesdrop_target clears. /// /// This prevents eavesdrop state leaking when the player walks out of the /// Eavesdrop Alcove: the very first movement cuts the focus. #[test] fn t6_eavesdrop_cut_on_player_movement() { let mut world = setup_world(); // NPC speaker A from Eavesdrop Alcove (StableId 58, abs 80, 32). let speaker_a = world.spawn(TilePosition::new(80, 32, 0)).id(); let speaker_a_sid = world.resource_mut::().register(speaker_a); // Player at eavesdrop corner position with active eavesdrop focus. let corner_pos = TilePosition::new(78, 36, 0); let mut focus = ListeningFocus::new(corner_pos); focus.stationary_ticks = EAVESDROP_THRESHOLD + 10; focus.eavesdrop_target = Some(speaker_a_sid); let player = world .spawn(( PlayerCharacter, corner_pos, focus, Stance(MovementStance::Careful), // Careful stance for eavesdrop )) .id(); // Pre-move: eavesdrop is active. { let f = world.get::(player).unwrap(); assert!( f.eavesdrop_target.is_some(), "T6 pre: eavesdrop_target must be set before movement" ); assert!( f.stationary_ticks > EAVESDROP_THRESHOLD, "T6 pre: stationary_ticks must exceed threshold" ); } // Player moves one step south (leaving eavesdrop corner). *world.get_mut::(player).unwrap() = TilePosition::new(78, 37, 0); run_listening_system(&mut world); // Eavesdrop must be cut. let f = world.get::(player).unwrap(); assert_eq!( f.stationary_ticks, 0, "T6 post: stationary_ticks must reset to 0 on movement" ); assert!( f.eavesdrop_target.is_none(), "T6 post: eavesdrop_target must clear when player leaves eavesdrop position" ); } // --------------------------------------------------------- // T7: Confrontation Interrupt on Room Exit // D-070, D-057, Confrontation Stage // --------------------------------------------------------- /// T7 — Confrontation Interrupt. /// /// Player at CLOSE_RANGE (distance 2) from NPC target in Confrontation Stage: /// Talk verb is available (confrontation is possible at this range). /// Player retreats to observer position (94, 20) — distance 8, beyond MID_RANGE=5: /// all NPC verbs disappear from the interaction buffer. /// /// This models the confrontation "interrupt" when the player moves away — /// the verb set changes, ending the potential confrontation. #[test] fn t7_confrontation_verb_disappears_on_retreat_beyond_mid_range() { let mut world = setup_world(); // NPC target at Confrontation Stage absolute position (94, 12). let npc = world .spawn((Npc, TilePosition::new(94, 12, 0), Interactable)) .id(); world.resource_mut::().register(npc); // Step 1: Player at (94, 14) — distance 2 from NPC (CLOSE_RANGE=2). let player = world .spawn(( PlayerCharacter, TilePosition::new(94, 14, 0), NearbyInteractionBuffer::default(), Stance(MovementStance::Walk), )) .id(); run_interaction_system(&mut world); let interactions = world .get_mut::(player) .unwrap() .take(); assert_eq!( interactions.len(), 1, "T7 close: NPC at distance 2 must appear in interaction buffer" ); assert!( interactions[0] .verbs .iter() .any(|v| v.kind == VerbKind::Talk), "T7 close: Talk must be available at CLOSE_RANGE (confrontation possible)" ); // Step 2: Player retreats to observer position (94, 20) — distance 8. // MID_RANGE = 5; distance 8 is fully out of range. *world.get_mut::(player).unwrap() = TilePosition::new(94, 20, 0); run_interaction_system(&mut world); let interactions = world .get_mut::(player) .unwrap() .take(); assert!( interactions.is_empty(), "T7 retreat: NPC at distance 8 must not appear in buffer (beyond MID_RANGE=5)" ); } // --------------------------------------------------------- // T8: Sprint Blocks Eavesdrop Accumulation (D-055 + D-071) // Sprint Gauntlet → Eavesdrop Alcove transition // --------------------------------------------------------- /// T8 — Sprint + Eavesdrop Cross-System Interaction. /// /// Sprint stance (D-055 high-alert) prevents stationary_ticks from /// accumulating in ListeningFocus (D-071), so eavesdrop cannot activate /// while the player is sprinting. /// /// Scenario: Player sprints through Sprint Gauntlet — 50 ticks stationary /// in Sprint stance → stationary_ticks stays at 0. Player then moves to /// Eavesdrop Alcove and switches to Careful stance. After /// EAVESDROP_THRESHOLD_CAREFUL stationary ticks, the threshold is met. /// /// This catches the cross-system bug: stale sprint state leaking into the /// eavesdrop counter if the Sprint check in update_listening_focus is absent. #[test] fn t8_sprint_blocks_eavesdrop_then_careful_enables_accumulation() { let mut world = setup_world(); // Player starts at Sprint Gauntlet observer position with Sprint stance. let sprint_pos = TilePosition::new(4, 12, 0); // abs (4,12) let player = world .spawn(( PlayerCharacter, sprint_pos, ListeningFocus::new(sprint_pos), Stance(MovementStance::Sprint), )) .id(); // 50 stationary ticks at Sprint — counter must not accumulate. for _ in 0..50 { run_listening_system(&mut world); } assert_eq!( world .get::(player) .unwrap() .stationary_ticks, 0, "T8 sprint: Sprint must block stationary_ticks (50 ticks at sprint, still 0)" ); // Transition: player moves to Eavesdrop Alcove, switches to Careful stance. let alcove_pos = TilePosition::new(78, 36, 0); *world.get_mut::(player).unwrap() = alcove_pos; world.get_mut::(player).unwrap().0 = MovementStance::Careful; // One tick to register the movement: system detects position change, // resets stationary_ticks to 0, and updates last_position to alcove_pos. run_listening_system(&mut world); assert_eq!( world .get::(player) .unwrap() .stationary_ticks, 0, "T8 transition: movement tick must reset stationary_ticks to 0" ); // EAVESDROP_THRESHOLD_CAREFUL stationary ticks in Careful stance. for _ in 0..EAVESDROP_THRESHOLD_CAREFUL { run_listening_system(&mut world); } { let f = world.get::(player).unwrap(); assert_eq!( f.stationary_ticks, EAVESDROP_THRESHOLD_CAREFUL, "T8 careful: stationary_ticks must accumulate cleanly after stance change" ); assert!( f.stationary_ticks >= EAVESDROP_THRESHOLD_CAREFUL, "T8 careful: stationary_ticks must meet Careful eavesdrop threshold" ); } // Verify D-071 invariant: Careful threshold is strictly less than normal. assert!( EAVESDROP_THRESHOLD_CAREFUL < EAVESDROP_THRESHOLD, "T8: Careful threshold must be < normal threshold (D-071 invariant)" ); }