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 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 16:34:21 +01:00
co-authored by Claude Opus 4.6
parent 61eb40fcab
commit daae3dd6ab
6 changed files with 501 additions and 3 deletions
+11
View File
@@ -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::<crate::npc::relationships::RelationshipGraph>()
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
.init_resource::<interaction::TerminalInteractedQueue>()
// Zone crossing event queue (#512, D-077): detect when player moves between zones.
.init_resource::<zone::ZoneCrossEventQueue>()
.init_resource::<zone::PreviousPlayerZone>()
.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");
+67
View File
@@ -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<u16>,
/// Zone the player entered (None = no zone on new tile).
pub to: Option<u16>,
}
/// Resource: queue of zone-crossing events (drained each tick by consumers).
#[derive(Resource, Default)]
pub struct ZoneCrossEventQueue {
pub events: Vec<ZoneCrossEvent>,
}
impl ZoneCrossEventQueue {
pub fn push(&mut self, event: ZoneCrossEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<ZoneCrossEvent> {
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<u16>);
/// 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<Res<ZoneMap>>,
mut previous_zone: ResMut<PreviousPlayerZone>,
player_query: Query<&TilePosition, With<PlayerCharacter>>,
mut queue: ResMut<ZoneCrossEventQueue>,
) {
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::*;
+56 -2
View File
@@ -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() {
+87 -1
View File
@@ -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::<TilePosition>(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
);
}
}
}
+1
View File
@@ -17,3 +17,4 @@ pub mod pause_chamber;
pub mod shift_change;
pub mod sound_lab;
pub mod sprint_gauntlet;
pub mod zone_gate;
+279
View File
@@ -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::<ZoneCrossEventQueue>();
world.init_resource::<PreviousPlayerZone>();
world.init_resource::<EntityRegistry>();
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::<WalkabilityMap>();
// 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::<ZoneMap>();
// 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::<ZoneCrossEventQueue>().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::<ZoneCrossEventQueue>().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::<ZoneCrossEventQueue>().drain();
// Move back into Terminal zone.
world.entity_mut(player).insert(OBSERVER_POS);
sched.run(&mut world);
let events = world.resource_mut::<ZoneCrossEventQueue>().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::<ZoneCrossEventQueue>().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::<ZoneCrossEventQueue>().drain();
assert!(
events.is_empty(),
"No ZoneCrossEvent must fire when player stays in Terminal zone"
);
}
}