//! Zone Gate — Room 15 (16x22) //! //! Tests D-077 zone assignment and zone-crossing detection (QA #512). //! //! The room is split at x=72 (absolute) into two zones: //! Terminal side (x=64-71): ZONE_GATE_TERMINAL_ZONE_ID (100) //! Corridor side (x=72-79): ZONE_GATE_CORRIDOR_ZONE_ID (101) //! //! Interior bounds after 2-tile wall carve: x=66-77, y=104-121. //! Zone boundary runs vertically at x=71/72 through the interior. //! //! The door entity (StableId 75) sits at (72, 112, 0) — the first tile //! of the Corridor zone. Moving from (71, 112, 0) to (72, 112, 0) crosses //! the zone boundary and triggers ZoneCrossEvent. //! //! Observer position: (70, 112, 0) absolute, facing East. //! //! Entities (StableId 75): //! door_zone_gate (75): open door at the Terminal/Corridor boundary use bevy_app::prelude::*; use crate::knowledge::registry::{EntityRegistry, StableEntityId}; use crate::simulation::interaction::{DoorState, Interactable, ObjectType}; use crate::simulation::movement::TilePosition; /// Observer start position — Terminal side of the zone boundary. pub const OBSERVER_POS: TilePosition = TilePosition { x: 70, y: 112, z: 0 }; /// Door position — first tile of the Corridor zone (zone boundary). pub const DOOR_POS: TilePosition = TilePosition { x: 72, y: 112, z: 0 }; /// Last walkable tile of the Terminal zone before the boundary. pub const TERMINAL_SIDE_POS: TilePosition = TilePosition { x: 71, y: 112, z: 0 }; /// First walkable tile of the Corridor zone after the boundary. pub const CORRIDOR_SIDE_POS: TilePosition = TilePosition { x: 73, y: 112, z: 0 }; /// Spawn Zone Gate entities in canonical order (StableId 75). pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { // door_zone_gate (StableId 75): open door at the zone boundary. // Starts open so the player can cross without an interact action. let entity = app .world_mut() .spawn(( Interactable, ObjectType::Door, DoorState { is_open: true, blocking_tile: DOOR_POS, }, DOOR_POS, )) .id(); let sid = registry.register(entity); app.world_mut() .entity_mut(entity) .insert(StableEntityId(sid)); } #[cfg(test)] mod tests { use super::*; use bevy_ecs::schedule::Schedule; use crate::knowledge::registry::EntityRegistry; use crate::simulation::movement::{PlayerCharacter, WalkabilityMap}; use crate::simulation::zone::{ detect_zone_crossings, PreviousPlayerZone, ZoneCrossEventQueue, ZoneMap, }; use crate::test_world::constants::{ZONE_GATE_CORRIDOR_ZONE_ID, ZONE_GATE_TERMINAL_ZONE_ID}; /// Build a minimal world representing the Zone Gate layout. /// /// Carves the room interior (x=66-77, y=104-121) as walkable. /// Assigns Terminal zone to the left half and Corridor zone to the right half. fn setup_zone_gate_world() -> bevy_ecs::world::World { let mut world = bevy_ecs::world::World::new(); // WalkabilityMap: carve Zone Gate interior (x=66-77, y=104-121). let mut wm = WalkabilityMap::new_blocked(117, 125, 1); for y in 104..=121 { for x in 66..=77 { wm.set_walkable(&TilePosition::new(x, y, 0), true); } } world.insert_resource(wm); // ZoneMap: Terminal (x=64-71) and Corridor (x=72-79) halves. let mut zone_map = ZoneMap::default(); zone_map.set_rect(64, 102, 8, 22, 0, ZONE_GATE_TERMINAL_ZONE_ID); zone_map.set_rect(72, 102, 8, 22, 0, ZONE_GATE_CORRIDOR_ZONE_ID); world.insert_resource(zone_map); // Zone crossing queue infrastructure. world.init_resource::(); world.init_resource::(); world.init_resource::(); world } /// Golden structure test: Zone Gate interior is correctly carved. #[test] fn zone_gate_interior_is_walkable() { let world = setup_zone_gate_world(); let wm = world.resource::(); // Interior tiles are walkable. assert!( wm.can_move_to(&OBSERVER_POS), "Observer position (70, 112) must be walkable" ); assert!( wm.can_move_to(&TERMINAL_SIDE_POS), "Terminal side (71, 112) must be walkable" ); assert!( wm.can_move_to(&DOOR_POS), "Door position (72, 112) must be walkable when door is open" ); assert!( wm.can_move_to(&CORRIDOR_SIDE_POS), "Corridor side (73, 112) must be walkable" ); // Outer wall tiles remain blocked. assert!( !wm.can_move_to(&TilePosition::new(65, 112, 0)), "Left wall at x=65 must be blocked" ); assert!( !wm.can_move_to(&TilePosition::new(78, 112, 0)), "Right wall at x=78 must be blocked" ); } /// Golden structure test: zone assignments are correct on both sides. #[test] fn zone_gate_zone_assignments_correct() { let world = setup_zone_gate_world(); let zone_map = world.resource::(); // Terminal side (x≤71) is in zone 100. assert_eq!( zone_map.zone_at(OBSERVER_POS.x, OBSERVER_POS.y, OBSERVER_POS.z), Some(ZONE_GATE_TERMINAL_ZONE_ID), "Observer pos must be in Terminal zone ({})", ZONE_GATE_TERMINAL_ZONE_ID ); assert_eq!( zone_map.zone_at(TERMINAL_SIDE_POS.x, TERMINAL_SIDE_POS.y, TERMINAL_SIDE_POS.z), Some(ZONE_GATE_TERMINAL_ZONE_ID), "Tile (71,112) must be in Terminal zone" ); // Corridor side (x≥72) is in zone 101. assert_eq!( zone_map.zone_at(DOOR_POS.x, DOOR_POS.y, DOOR_POS.z), Some(ZONE_GATE_CORRIDOR_ZONE_ID), "Door tile (72,112) must be in Corridor zone ({})", ZONE_GATE_CORRIDOR_ZONE_ID ); assert_eq!( zone_map.zone_at( CORRIDOR_SIDE_POS.x, CORRIDOR_SIDE_POS.y, CORRIDOR_SIDE_POS.z ), Some(ZONE_GATE_CORRIDOR_ZONE_ID), "Tile (73,112) must be in Corridor zone" ); } /// Acceptance test: ZoneCrossEvent queued when player crosses the zone boundary. /// /// Player starts in Terminal zone at (70, 112, 0). /// Player moves to Corridor zone at (72, 112, 0). /// detect_zone_crossings must queue a ZoneCrossEvent: /// from: Some(TERMINAL_ZONE_ID), to: Some(CORRIDOR_ZONE_ID) #[test] fn zone_gate_crossing_fires_zone_cross_event() { let mut world = setup_zone_gate_world(); // Spawn player in Terminal zone. let player = world.spawn((PlayerCharacter, OBSERVER_POS)).id(); // Tick 1: player at Observer pos — PreviousPlayerZone is None (initial), // current zone is Terminal. Run detect_zone_crossings to initialise. let mut sched = Schedule::default(); sched.add_systems(detect_zone_crossings); sched.run(&mut world); // Drain the first event (None → Terminal transition on init). world.resource_mut::().drain(); // Move player to DOOR_POS (x=72) — crosses into Corridor zone. world.entity_mut(player).insert(DOOR_POS); sched.run(&mut world); // Read the crossing event. let events = world.resource_mut::().drain(); let crossing = events.into_iter().next(); assert!( crossing.is_some(), "ZoneCrossEvent must fire when player moves from Terminal to Corridor zone" ); let ev = crossing.unwrap(); assert_eq!( ev.from, Some(ZONE_GATE_TERMINAL_ZONE_ID), "ZoneCrossEvent.from must be Terminal zone ({})", ZONE_GATE_TERMINAL_ZONE_ID ); assert_eq!( ev.to, Some(ZONE_GATE_CORRIDOR_ZONE_ID), "ZoneCrossEvent.to must be Corridor zone ({})", ZONE_GATE_CORRIDOR_ZONE_ID ); } /// Crossing in reverse (Corridor → Terminal) also queues a ZoneCrossEvent. #[test] fn zone_gate_reverse_crossing_fires_event() { let mut world = setup_zone_gate_world(); // Spawn player in Corridor zone. let player = world.spawn((PlayerCharacter, DOOR_POS)).id(); let mut sched = Schedule::default(); sched.add_systems(detect_zone_crossings); // Initialise PreviousPlayerZone. sched.run(&mut world); world.resource_mut::().drain(); // Move back into Terminal zone. world.entity_mut(player).insert(OBSERVER_POS); sched.run(&mut world); let events = world.resource_mut::().drain(); let crossing = events.into_iter().next(); assert!( crossing.is_some(), "ZoneCrossEvent must fire when player returns to Terminal zone" ); let ev = crossing.unwrap(); assert_eq!(ev.from, Some(ZONE_GATE_CORRIDOR_ZONE_ID)); assert_eq!(ev.to, Some(ZONE_GATE_TERMINAL_ZONE_ID)); } /// No event queued when player stays within the same zone. #[test] fn zone_gate_no_event_within_same_zone() { let mut world = setup_zone_gate_world(); let player = world.spawn((PlayerCharacter, OBSERVER_POS)).id(); let mut sched = Schedule::default(); sched.add_systems(detect_zone_crossings); // Initialise. sched.run(&mut world); world.resource_mut::().drain(); // Move within Terminal zone (same zone, different tile). world.entity_mut(player).insert(TilePosition::new(68, 112, 0)); sched.run(&mut world); let events = world.resource_mut::().drain(); assert!( events.is_empty(), "No ZoneCrossEvent must fire when player stays in Terminal zone" ); } }