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:
@@ -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");
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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