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>
280 lines
10 KiB
Rust
280 lines
10 KiB
Rust
//! 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"
|
|
);
|
|
}
|
|
}
|