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:
@@ -17,3 +17,4 @@ pub mod pause_chamber;
|
||||
pub mod shift_change;
|
||||
pub mod sound_lab;
|
||||
pub mod sprint_gauntlet;
|
||||
pub mod zone_gate;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user