From daae3dd6ab85d3884276c7235e2725fbe8ecee51 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 28 Feb 2026 16:34:21 +0100 Subject: [PATCH] feat(simulation): add Zone Gate gauntlet room and zone crossing detection (#512) New test_world room with two zones (Terminal/Corridor) separated by a door. Adds ZoneCrossEventQueue resource and detect_zone_crossings system to fire events when the player crosses zone boundaries. Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/mod.rs | 11 + server/src/simulation/zone.rs | 67 ++++++ server/src/test_world/constants.rs | 58 ++++- server/src/test_world/mod.rs | 88 ++++++- server/src/test_world/rooms/mod.rs | 1 + server/src/test_world/rooms/zone_gate.rs | 279 +++++++++++++++++++++++ 6 files changed, 501 insertions(+), 3 deletions(-) create mode 100644 server/src/test_world/rooms/zone_gate.rs diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index f0d6958d5..048b4a655 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -9,10 +9,12 @@ pub mod conversation; pub mod dialogue; pub mod examine; pub mod follow; +pub mod generator; pub mod input; pub mod interaction; pub mod inventory; pub mod listening; +pub mod modification; pub mod monologue; pub mod movement; pub mod npc_knowledge_transfer; @@ -63,6 +65,9 @@ impl Plugin for SimulationPlugin { .init_resource::() .init_resource::() .init_resource::() + // Zone crossing event queue (#512, D-077): detect when player moves between zones. + .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -116,6 +121,12 @@ impl Plugin for SimulationPlugin { .before(crate::perception::observer::compute_observer_snapshot), time::advance_tick.after(path_follow::cleanup_path_blocked), ), + ) + // Zone crossing detection (#512, D-077) — separate call to stay within + // Bevy's 20-element system tuple limit. + .add_systems( + Update, + zone::detect_zone_crossings.after(movement::validate_movement), ); tracing::debug!("SimulationPlugin initialized"); diff --git a/server/src/simulation/zone.rs b/server/src/simulation/zone.rs index ebefd79ef..e1dff9c00 100644 --- a/server/src/simulation/zone.rs +++ b/server/src/simulation/zone.rs @@ -4,6 +4,7 @@ //! - Observer snapshot: enriches VisibleTile with zone_id //! - Client AudioManager: zone crossfade triggers (D-073) //! - Client fog shader: deep fog temperature tint (D-059 layer 3) +//! - Zone Gate Gauntlet room: crossing-event detection (QA #512) //! //! BTreeMap per D-010 principle 4 (deterministic iteration). @@ -11,6 +12,8 @@ use std::collections::BTreeMap; use bevy_ecs::prelude::*; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; + /// Server-authoritative zone assignment for tiles. /// /// Each tile position maps to a zone ID. Tiles outside any defined zone @@ -48,6 +51,70 @@ impl ZoneMap { } } +/// Event emitted when the player crosses a zone boundary (D-077). +/// +/// Queued once per tick when the player's tile zone_id differs from the +/// previously recorded zone. `from` or `to` may be None for tiles outside +/// any defined zone (corridors, unzoned transitions). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ZoneCrossEvent { + /// Zone the player left (None = no zone on previous tile). + pub from: Option, + /// Zone the player entered (None = no zone on new tile). + pub to: Option, +} + +/// Resource: queue of zone-crossing events (drained each tick by consumers). +#[derive(Resource, Default)] +pub struct ZoneCrossEventQueue { + pub events: Vec, +} + +impl ZoneCrossEventQueue { + pub fn push(&mut self, event: ZoneCrossEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } +} + +/// Tracks the player's zone from the previous tick. +/// +/// Used by `detect_zone_crossings` to compare against the current tick's +/// zone and emit a `ZoneCrossEvent` when they differ. +#[derive(Resource, Debug, Default)] +pub struct PreviousPlayerZone(pub Option); + +/// System: detect zone crossings and queue a `ZoneCrossEvent`. +/// +/// Runs after `validate_movement` so the player position is current. +/// Compares the player's current tile zone (from `ZoneMap`) against +/// `PreviousPlayerZone`. Queues a `ZoneCrossEvent` on change and updates +/// the resource for the next tick. +pub fn detect_zone_crossings( + zone_map: Option>, + mut previous_zone: ResMut, + player_query: Query<&TilePosition, With>, + mut queue: ResMut, +) { + let Some(zone_map) = zone_map else { + return; + }; + let Ok(pos) = player_query.single() else { + return; + }; + let current_zone = zone_map.zone_at(pos.x, pos.y, pos.z); + if current_zone != previous_zone.0 { + queue.push(ZoneCrossEvent { + from: previous_zone.0, + to: current_zone, + }); + previous_zone.0 = current_zone; + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/src/test_world/constants.rs b/server/src/test_world/constants.rs index 1459779bf..5a0766ac9 100644 --- a/server/src/test_world/constants.rs +++ b/server/src/test_world/constants.rs @@ -255,6 +255,49 @@ pub const SHIFT_CHANGE: GauntletRoom = GauntletRoom { reset_plate: Some(TilePosition { x: 72, y: 78, z: 0 }), }; +/// Zone Gate — Room 15 (16x22) +/// Tests D-077 zone assignment and zone-crossing detection (#512). +/// +/// Room is split at x=72 (absolute) into two zones: +/// Terminal side (x=64-71): ZONE_GATE_TERMINAL_ZONE_ID (zone_id=100) +/// Corridor side (x=72-79): ZONE_GATE_CORRIDOR_ZONE_ID (zone_id=101) +/// +/// A door entity sits on the boundary at (72, 112, 0). Moving from the +/// Terminal side to the Corridor side (or vice versa) fires ZoneCrossEvent. +/// +/// Observer starts on the Terminal side at (70, 112, 0), facing East. +pub const ZONE_GATE: GauntletRoom = GauntletRoom { + name: "zone_gate", + origin: TilePosition { + x: 64, + y: 102, + z: 0, + }, + size: (16, 22), + spawn: TilePosition { + x: 70, + y: 112, + z: 0, + }, + observer: TilePosition { + x: 70, + y: 112, + z: 0, + }, + observer_facing: Facing(FacingDirection::East), + reset_plate: Some(TilePosition { + x: 72, + y: 102, + z: 0, + }), +}; + +/// Zone ID for the Terminal (left/west) half of the Zone Gate room. +pub const ZONE_GATE_TERMINAL_ZONE_ID: u16 = 100; + +/// Zone ID for the Corridor (right/east) half of the Zone Gate room. +pub const ZONE_GATE_CORRIDOR_ZONE_ID: u16 = 101; + /// All rooms in canonical spawn order. /// THIS ORDER DETERMINES STABLEID ASSIGNMENT. /// Do not reorder existing entries. Append new rooms at the end. @@ -273,6 +316,7 @@ pub const ROOMS: &[GauntletRoom] = &[ SOUND_LAB, DECAY_OBSERVATORY, SHIFT_CHANGE, + ZONE_GATE, ]; /// Look up which room a position falls in. @@ -323,6 +367,11 @@ pub const DECAY_OBSERVATORY_STABLE_IDS: (u64, u64) = (69, 69); pub const SHIFT_CHANGE_STABLE_IDS: (u64, u64) = (70, 71); /// Reset plates for Sprint 13 rooms (sound_lab, decay_observatory, shift_change). pub const SPRINT13_RESET_PLATE_STABLE_IDS: (u64, u64) = (72, 74); +// Sprint 22 rooms — appended after SPRINT13_RESET_PLATE_STABLE_IDS per additive-only rule. +/// Zone Gate room: door entity at the zone boundary. +pub const ZONE_GATE_STABLE_IDS: (u64, u64) = (75, 75); +/// Reset plate for Sprint 22 rooms (zone_gate). +pub const SPRINT22_RESET_PLATE_STABLE_IDS: (u64, u64) = (76, 76); /// Number of actively-spawned entities in the current Gauntlet build. /// Derived from StableId ranges of all rooms + player + reset plates. @@ -343,7 +392,9 @@ pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0) + (SOUND_LAB_STABLE_IDS.1 - SOUND_LAB_STABLE_IDS.0 + 1) as usize + (DECAY_OBSERVATORY_STABLE_IDS.1 - DECAY_OBSERVATORY_STABLE_IDS.0 + 1) as usize + (SHIFT_CHANGE_STABLE_IDS.1 - SHIFT_CHANGE_STABLE_IDS.0 + 1) as usize - + (SPRINT13_RESET_PLATE_STABLE_IDS.1 - SPRINT13_RESET_PLATE_STABLE_IDS.0 + 1) as usize; + + (SPRINT13_RESET_PLATE_STABLE_IDS.1 - SPRINT13_RESET_PLATE_STABLE_IDS.0 + 1) as usize + + (ZONE_GATE_STABLE_IDS.1 - ZONE_GATE_STABLE_IDS.0 + 1) as usize + + (SPRINT22_RESET_PLATE_STABLE_IDS.1 - SPRINT22_RESET_PLATE_STABLE_IDS.0 + 1) as usize; #[cfg(test)] mod tests { @@ -452,7 +503,7 @@ mod tests { #[test] fn all_rooms_in_correct_order() { - assert_eq!(ROOMS.len(), 14); + assert_eq!(ROOMS.len(), 15); assert_eq!(ROOMS[0].name, "central_hub"); assert_eq!(ROOMS[1].name, "fog_theater"); assert_eq!(ROOMS[2].name, "occlusion_corridor"); @@ -467,6 +518,7 @@ mod tests { assert_eq!(ROOMS[11].name, "sound_lab"); assert_eq!(ROOMS[12].name, "decay_observatory"); assert_eq!(ROOMS[13].name, "shift_change"); + assert_eq!(ROOMS[14].name, "zone_gate"); } #[test] @@ -533,6 +585,8 @@ mod tests { DECAY_OBSERVATORY_STABLE_IDS, SHIFT_CHANGE_STABLE_IDS, SPRINT13_RESET_PLATE_STABLE_IDS, + ZONE_GATE_STABLE_IDS, + SPRINT22_RESET_PLATE_STABLE_IDS, ]; for (i, a) in ranges.iter().enumerate() { for (j, b) in ranges.iter().enumerate() { diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 363344845..91f14c0ab 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -16,7 +16,7 @@ //! - Entities spawned in canonical order → StableId assignment is deterministic //! - Additive-only: existing rooms/entities never reordered //! -//! StableId ranges (from gestalt-round3.md + Sprint 11 + Sprint 13): +//! StableId ranges (from gestalt-round3.md + Sprint 11 + Sprint 13 + Sprint 22): //! Player: 0 //! Hub signs: 1-4 //! Fog Theater: 5-8 @@ -35,6 +35,8 @@ //! Decay Observatory: 69 //! Shift Change: 70-71 //! Reset plates (Sprint 13 rooms): 72-74 +//! Zone Gate: 75 (door entity) +//! Reset plates (Sprint 22 rooms): 76 #[cfg(feature = "gauntlet")] pub mod constants; @@ -113,6 +115,8 @@ pub fn setup_gauntlet(app: &mut App) { carve_room_interior(&mut walkability, 0, 104, 34, 20); // Sound Lab carve_room_interior(&mut walkability, 0, 24, 24, 14); // Decay Observatory carve_room_interior(&mut walkability, 64, 78, 16, 24); // Shift Change + // Sprint 22 rooms + carve_room_interior(&mut walkability, 64, 102, 16, 22); // Zone Gate // Carve corridors between hub and rooms carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater @@ -144,8 +148,13 @@ pub fn setup_gauntlet(app: &mut App) { // Zone map: assign zone IDs per room bounding box (D-077, D-073). // Zone IDs are sequential per ROOMS order. Corridors remain unzoned (None). + // Exception: Zone Gate (index 14) uses two explicit zone IDs for its dual-zone split. let mut zone_map = ZoneMap::default(); for (i, room) in constants::ROOMS.iter().enumerate() { + // Zone Gate handled separately below — skip in the sequential loop. + if room.name == "zone_gate" { + continue; + } let zone_id = i as u16; zone_map.set_rect( room.origin.x, @@ -156,6 +165,23 @@ pub fn setup_gauntlet(app: &mut App) { zone_id, ); } + // Zone Gate: Terminal side (left 8 cols, x=64-71) and Corridor side (right 8 cols, x=72-79). + zone_map.set_rect( + constants::ZONE_GATE.origin.x, + constants::ZONE_GATE.origin.y, + constants::ZONE_GATE.size.0 / 2, + constants::ZONE_GATE.size.1, + constants::ZONE_GATE.origin.z, + constants::ZONE_GATE_TERMINAL_ZONE_ID, + ); + zone_map.set_rect( + constants::ZONE_GATE.origin.x + constants::ZONE_GATE.size.0 / 2, + constants::ZONE_GATE.origin.y, + constants::ZONE_GATE.size.0 / 2, + constants::ZONE_GATE.size.1, + constants::ZONE_GATE.origin.z, + constants::ZONE_GATE_CORRIDOR_ZONE_ID, + ); app.insert_resource(zone_map); // Entity spawning in canonical StableId order. @@ -333,6 +359,9 @@ pub fn setup_gauntlet(app: &mut App) { } // --- Sprint 13 reset plates (StableId 72-74) --- + // NOTE: Sprint 22 reset plate (zone_gate) is spawned AFTER these to maintain additive order. + // The sprint13 block is numbered 72-74; zone_gate entities are 75; sprint22 plate is 76. + // The spawn order below matches the StableId allocation table in the module doc. let sprint13_reset_plates: &[(&str, TilePosition)] = &[ ( "sound_lab", @@ -370,6 +399,34 @@ pub fn setup_gauntlet(app: &mut App) { .insert(StableEntityId(sid)); } + // --- Zone Gate (StableId 75) --- + // Spawned AFTER Sprint 13 reset plates so it gets ID 75 per the allocation table. + rooms::zone_gate::spawn_entities(app, &mut registry); + + // --- Sprint 22 reset plates (StableId 76) --- + let sprint22_reset_plates: &[(&str, TilePosition)] = &[( + "zone_gate", + constants::ZONE_GATE + .reset_plate + .expect("zone_gate should have a reset_plate"), + )]; + for &(room_name, pos) in sprint22_reset_plates { + let entity = app + .world_mut() + .spawn(( + Interactable, + RoomResetTrigger { + room_name: room_name.to_string(), + }, + pos, + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + } + // --- Populate RoomSnapshots for reset mechanism (#490) --- let mut snapshots = RoomSnapshots::default(); @@ -495,6 +552,15 @@ pub fn setup_gauntlet(app: &mut App) { } } + // Zone Gate entities (StableId 75): door only, no floor items + for id in constants::ZONE_GATE_STABLE_IDS.0..=constants::ZONE_GATE_STABLE_IDS.1 { + if let Some(entity) = registry.to_entity(&StableId(id)) { + if let Some(pos) = app.world().get::(entity) { + snapshots.record("zone_gate", entity, *pos, false); + } + } + } + app.insert_resource(snapshots); // --- Sprint 14 component fixup --- @@ -864,5 +930,25 @@ mod tests { id ); } + + // Zone Gate at 75 + for id in constants::ZONE_GATE_STABLE_IDS.0..=constants::ZONE_GATE_STABLE_IDS.1 { + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Zone Gate at StableId {}", + id + ); + } + + // Sprint 22 reset plate at 76 + for id in constants::SPRINT22_RESET_PLATE_STABLE_IDS.0 + ..=constants::SPRINT22_RESET_PLATE_STABLE_IDS.1 + { + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Sprint 22 reset plate at StableId {}", + id + ); + } } } diff --git a/server/src/test_world/rooms/mod.rs b/server/src/test_world/rooms/mod.rs index 14889c1cc..86332f274 100644 --- a/server/src/test_world/rooms/mod.rs +++ b/server/src/test_world/rooms/mod.rs @@ -17,3 +17,4 @@ pub mod pause_chamber; pub mod shift_change; pub mod sound_lab; pub mod sprint_gauntlet; +pub mod zone_gate; diff --git a/server/src/test_world/rooms/zone_gate.rs b/server/src/test_world/rooms/zone_gate.rs new file mode 100644 index 000000000..718848b14 --- /dev/null +++ b/server/src/test_world/rooms/zone_gate.rs @@ -0,0 +1,279 @@ +//! 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" + ); + } +}