//! Gauntlet test world — purpose-built rooms for systematic QA testing. //! //! NOT production content. This module provides deterministic room layouts //! with precise entity placement for golden file testing, regression testing, //! and manual QA sessions. Loaded instead of content/ when the server runs //! the Gauntlet map. //! //! Module structure: //! - `reset` — always compiled (used by simulation::input in production) //! - `constants`, `rooms`, `setup_gauntlet` — gated behind "gauntlet" feature //! (default-on; strip with --no-default-features for release builds) //! //! Architecture (per workshop-outcomes.md Section 4): //! - Rooms are defined as Rust builder functions (hybrid YAML + Rust inject) //! - WalkabilityMap covers the full Gauntlet bounds (0-116 x 0-124) //! - Entities spawned in canonical order → StableId assignment is deterministic //! - Additive-only: existing rooms/entities never reordered //! //! StableId ranges (from gestalt-round3.md + Sprint 11): //! Player: 0 //! Hub signs: 1-4 //! Fog Theater: 5-8 //! Occlusion Corridor: 9-12 //! Inventory Warehouse: 13-23 //! Interaction Gallery: 24-28 //! Pause Chamber: 29 //! Dialogue Room: 30-33 //! Crowd Plaza: 34-48 //! Reset plates (Sprint 1-10 rooms): 49-55 //! Sprint Gauntlet: 56-57 //! Eavesdrop Alcove: 58-60 //! Confrontation Stage: 61-62 //! Reset plates (Sprint 11 rooms): 63-65 #[cfg(feature = "gauntlet")] pub mod constants; pub mod reset; #[cfg(feature = "gauntlet")] pub mod rooms; #[cfg(feature = "gauntlet")] use bevy_app::prelude::*; #[cfg(feature = "gauntlet")] use crate::knowledge::registry::{EntityRegistry, StableEntityId}; #[cfg(feature = "gauntlet")] use crate::knowledge::types::StableId; #[cfg(feature = "gauntlet")] use crate::knowledge::KnowledgeGraph; #[cfg(feature = "gauntlet")] use crate::perception::cognitive_delay::CognitiveDelay; #[cfg(feature = "gauntlet")] use crate::perception::vision_cone::Facing; #[cfg(feature = "gauntlet")] use crate::simulation::interaction::{Interactable, NearbyInteractionBuffer}; #[cfg(feature = "gauntlet")] use crate::simulation::inventory::ItemName; #[cfg(feature = "gauntlet")] use crate::simulation::listening::ListeningFocus; #[cfg(feature = "gauntlet")] use crate::simulation::contraband::ScanEventBuffer; use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue}; #[cfg(feature = "gauntlet")] use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; #[cfg(feature = "gauntlet")] use crate::simulation::stance::{MovementProfile, PlayerMoveCooldown}; #[cfg(feature = "gauntlet")] use reset::{RoomResetTrigger, RoomSnapshots}; #[cfg(feature = "gauntlet")] /// Map bounds for the full Gauntlet world. pub const MAP_WIDTH: i32 = 117; #[cfg(feature = "gauntlet")] pub const MAP_HEIGHT: i32 = 125; /// Set up the Gauntlet test world. /// /// Creates the full WalkabilityMap (blocked by default), carves room /// interiors and corridors, spawns the player and all room entities /// in canonical StableId order. /// /// NOTE: This bypasses the normal startup system scheduler — entities are /// spawned directly into the World rather than via startup systems. This /// is intentional for deterministic test setups but should be revisited /// if Gauntlet is ever served by the production startup pipeline. #[cfg(feature = "gauntlet")] pub fn setup_gauntlet(app: &mut App) { // Start with a fully blocked map, then carve rooms and corridors. let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1); // Carve room interiors (2-tile-thick walls → interior starts 2 tiles in) carve_room_interior(&mut walkability, 38, 46, 24, 24); // Hub carve_room_interior(&mut walkability, 28, 2, 44, 32); // Fog Theater carve_room_interior(&mut walkability, 74, 48, 42, 22); // Occlusion Corridor carve_room_interior(&mut walkability, 2, 40, 30, 28); // Inventory Warehouse carve_room_interior(&mut walkability, 2, 82, 24, 20); // Interaction Gallery carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber carve_room_interior(&mut walkability, 36, 104, 28, 20); // Dialogue Room carve_room_interior(&mut walkability, 80, 78, 32, 32); // Crowd Plaza carve_room_interior(&mut walkability, 0, 2, 28, 20); // Sprint Gauntlet carve_room_interior(&mut walkability, 74, 26, 24, 16); // Eavesdrop Alcove carve_room_interior(&mut walkability, 84, 2, 32, 24); // Confrontation Stage // Carve corridors between hub and rooms carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater carve_corridor(&mut walkability, 62, 55, 12, 6); // corridor-E: Hub ↔ Occlusion carve_corridor(&mut walkability, 32, 55, 6, 6); // corridor-W: Hub ↔ Inventory carve_corridor(&mut walkability, 47, 70, 6, 8); // corridor-S: Hub ↔ Pause Chamber carve_corridor(&mut walkability, 12, 68, 6, 14); // corridor-SW: Inventory ↔ Interaction Gallery carve_corridor(&mut walkability, 48, 94, 6, 10); // corridor-S2: Pause ↔ Dialogue Room carve_corridor(&mut walkability, 58, 84, 22, 6); // corridor-E2: Pause ↔ Crowd Plaza // Sprint 11 corridors carve_corridor(&mut walkability, 12, 22, 6, 18); // corridor-NW: Sprint Gauntlet ↔ Inventory south carve_corridor(&mut walkability, 62, 30, 12, 6); // corridor-NE: Eavesdrop Alcove ↔ Occlusion north // Set up Occlusion Corridor walls (relative positions converted to absolute) // North wall segment: rel x=[14,22], y=[6,7] — blocks LOS to npc_hidden_wall for x in 88..=96 { for y in 54..=55 { walkability.set_walkable(&TilePosition::new(x, y, 0), false); } } // South alcove walls: rel x=[4,8], y=[12,13] for x in 78..=82 { for y in 60..=61 { walkability.set_walkable(&TilePosition::new(x, y, 0), false); } } app.insert_resource(walkability); // Entity spawning in canonical StableId order. // Player gets StableId 0, then entities by room in workshop order. let mut registry = EntityRegistry::new(0); // --- Player (StableId 0) --- // Spawn at Hub center: absolute (50, 58) let profile = MovementProfile::smuggler(); let player_pos = TilePosition::new(50, 58, 0); let player = app .world_mut() .spawn(( PlayerCharacter, player_pos, Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), MonologueState::default(), MonologueBuffer::default(), SprintAnomalyQueue::default(), ScanEventBuffer::default(), CognitiveDelay::default(), ListeningFocus::new(player_pos), profile, profile.initial_stance(), PlayerMoveCooldown::default(), )) .id(); registry.register(player); // --- Hub signs (StableId 1-4) --- rooms::hub::spawn_entities(app, &mut registry); // --- Fog Theater (StableId 5-8) --- rooms::fog_theater::spawn_entities(app, &mut registry); // --- Occlusion Corridor (StableId 9-12) --- rooms::occlusion_corridor::spawn_entities(app, &mut registry); // --- Inventory Warehouse (StableId 13-23) --- rooms::inventory_warehouse::spawn_entities(app, &mut registry); // --- Interaction Gallery (StableId 24-28) --- rooms::interaction_gallery::spawn_entities(app, &mut registry); // --- Pause Chamber (StableId 29) --- rooms::pause_chamber::spawn_entities(app, &mut registry); // --- Dialogue Room (StableId 30-33) --- rooms::dialogue_room::spawn_entities(app, &mut registry); // --- Crowd Plaza (StableId 34-48) --- rooms::crowd_plaza::spawn_entities(app, &mut registry); // --- Reset plates (StableId 49-55) --- // Spawned at corridor entrances per workshop-outcomes.md Section 8. // Each plate triggers reset of its associated room. let reset_plates: &[(&str, TilePosition)] = &[ ( "occlusion_corridor", constants::OCCLUSION_CORRIDOR .reset_plate .expect("occlusion_corridor should have a reset_plate"), ), ( "inventory_warehouse", constants::INVENTORY_WAREHOUSE .reset_plate .expect("inventory_warehouse should have a reset_plate"), ), ( "pause_chamber", constants::PAUSE_CHAMBER .reset_plate .expect("pause_chamber should have a reset_plate"), ), ( "fog_theater", constants::FOG_THEATER .reset_plate .expect("fog_theater should have a reset_plate"), ), ( "interaction_gallery", constants::INTERACTION_GALLERY .reset_plate .expect("interaction_gallery should have a reset_plate"), ), ( "dialogue_room", constants::DIALOGUE_ROOM .reset_plate .expect("dialogue_room should have a reset_plate"), ), ( "crowd_plaza", constants::CROWD_PLAZA .reset_plate .expect("crowd_plaza should have a reset_plate"), ), ]; for &(room_name, pos) in 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)); } // --- Sprint Gauntlet (StableId 56-57) --- rooms::sprint_gauntlet::spawn_entities(app, &mut registry); // --- Eavesdrop Alcove (StableId 58-60) --- rooms::eavesdrop_alcove::spawn_entities(app, &mut registry); // --- Confrontation Stage (StableId 61-62) --- rooms::confrontation_stage::spawn_entities(app, &mut registry); // --- Sprint 11 reset plates (StableId 63-65) --- let sprint11_reset_plates: &[(&str, TilePosition)] = &[ ( "sprint_gauntlet", constants::SPRINT_GAUNTLET .reset_plate .expect("sprint_gauntlet should have a reset_plate"), ), ( "eavesdrop_alcove", constants::EAVESDROP_ALCOVE .reset_plate .expect("eavesdrop_alcove should have a reset_plate"), ), ( "confrontation_stage", constants::CONFRONTATION_STAGE .reset_plate .expect("confrontation_stage should have a reset_plate"), ), ]; for &(room_name, pos) in sprint11_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(); // Fog Theater entities (StableId 5-8): NPCs only for id in constants::FOG_THEATER_STABLE_IDS.0..=constants::FOG_THEATER_STABLE_IDS.1 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("fog_theater", entity, *pos, false); } } } // Occlusion Corridor entities (StableId 9-12): NPCs only, no floor items for id in 9..=12 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("occlusion_corridor", entity, *pos, false); } } } // Inventory Warehouse entities (StableId 13-23): crates are floor items, NPC is not for id in 13..=23 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { let is_floor_item = app.world().get::(entity).is_some(); snapshots.record("inventory_warehouse", entity, *pos, is_floor_item); } } } // Interaction Gallery entities (StableId 24-28): objects only, no floor items for id in constants::INTERACTION_GALLERY_STABLE_IDS.0..=constants::INTERACTION_GALLERY_STABLE_IDS.1 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("interaction_gallery", entity, *pos, false); } } } // Pause Chamber entity (StableId 29): NPC only if let Some(entity) = registry.to_entity(&StableId(29)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("pause_chamber", entity, *pos, false); } } // Dialogue Room entities (StableId 30-33): NPCs only for id in constants::DIALOGUE_ROOM_STABLE_IDS.0..=constants::DIALOGUE_ROOM_STABLE_IDS.1 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("dialogue_room", entity, *pos, false); } } } // Crowd Plaza entities (StableId 34-48): NPCs only for id in constants::CROWD_PLAZA_STABLE_IDS.0..=constants::CROWD_PLAZA_STABLE_IDS.1 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("crowd_plaza", entity, *pos, false); } } } // Sprint Gauntlet entities (StableId 56-57): sign + NPC, no floor items for id in constants::SPRINT_GAUNTLET_STABLE_IDS.0..=constants::SPRINT_GAUNTLET_STABLE_IDS.1 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("sprint_gauntlet", entity, *pos, false); } } } // Eavesdrop Alcove entities (StableId 58-60): NPCs + sign, no floor items for id in constants::EAVESDROP_ALCOVE_STABLE_IDS.0..=constants::EAVESDROP_ALCOVE_STABLE_IDS.1 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("eavesdrop_alcove", entity, *pos, false); } } } // Confrontation Stage entities (StableId 61-62): NPCs only, no floor items for id in constants::CONFRONTATION_STAGE_STABLE_IDS.0..=constants::CONFRONTATION_STAGE_STABLE_IDS.1 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("confrontation_stage", entity, *pos, false); } } } app.insert_resource(snapshots); app.insert_resource(registry); } #[cfg(feature = "gauntlet")] /// Carve the walkable interior of a room. /// Room has 2-tile-thick walls; interior starts 2 tiles inside each boundary. fn carve_room_interior(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32) { for y in (oy + 2)..(oy + h - 2) { for x in (ox + 2)..(ox + w - 2) { wm.set_walkable(&TilePosition::new(x, y, 0), true); } } } #[cfg(feature = "gauntlet")] /// Carve a corridor as a fully walkable rectangle. /// Corridors are 6 tiles wide with walls on both sides — we carve the /// interior 2 tiles in from each edge (leaving 2-tile walkable center). /// For narrow corridors (width=6), interior is 2 tiles wide. fn carve_corridor(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32) { // Corridors are fully walkable rectangles (wall tiles are the // surrounding room/map boundary). Carve interior with 1-tile margin. for y in (oy + 1)..(oy + h - 1) { for x in (ox + 1)..(ox + w - 1) { wm.set_walkable(&TilePosition::new(x, y, 0), true); } } } #[cfg(all(test, feature = "gauntlet"))] mod tests { use super::*; use crate::knowledge::registry::EntityRegistry; #[test] fn gauntlet_setup_creates_expected_entities() { let mut app = App::new(); app.add_plugins(crate::simulation::SimulationPlugin); app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); setup_gauntlet(&mut app); let registry = app.world().resource::(); assert_eq!( registry.len(), constants::EXPECTED_ENTITY_COUNT, "entity count should match EXPECTED_ENTITY_COUNT derived from StableId ranges" ); } #[test] fn hub_center_is_walkable() { let mut app = App::new(); app.add_plugins(crate::simulation::SimulationPlugin); app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); setup_gauntlet(&mut app); let wm = app.world().resource::(); // Hub center at (50, 58) must be walkable assert!(wm.can_move_to(&TilePosition::new(50, 58, 0))); } #[test] fn occlusion_north_wall_blocks() { let mut app = App::new(); app.add_plugins(crate::simulation::SimulationPlugin); app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); setup_gauntlet(&mut app); let wm = app.world().resource::(); // North wall segment at absolute (90, 54) should be blocked assert!(!wm.can_move_to(&TilePosition::new(90, 54, 0))); // But the corridor interior at (90, 56) should be walkable assert!(wm.can_move_to(&TilePosition::new(90, 56, 0))); } #[test] fn corridor_connects_hub_to_occlusion() { let mut app = App::new(); app.add_plugins(crate::simulation::SimulationPlugin); app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); setup_gauntlet(&mut app); let wm = app.world().resource::(); // corridor-E center should be walkable assert!(wm.can_move_to(&TilePosition::new(66, 57, 0))); } #[test] fn stable_id_ranges_match_spec() { let mut app = App::new(); app.add_plugins(crate::simulation::SimulationPlugin); app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); setup_gauntlet(&mut app); let registry = app.world().resource::(); // Player at StableId 0 use crate::knowledge::types::StableId; assert!( registry.to_entity(&StableId(0)).is_some(), "Player at StableId 0" ); // Hub signs at 1-4 for id in 1..=4 { assert!( registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id ); } // Fog Theater at 5-8 for id in 5..=8 { assert!( registry.to_entity(&StableId(id)).is_some(), "Fog Theater at StableId {}", id ); } // Occlusion Corridor at 9-12 for id in 9..=12 { assert!( registry.to_entity(&StableId(id)).is_some(), "Occlusion at StableId {}", id ); } // Inventory Warehouse at 13-23 for id in 13..=23 { assert!( registry.to_entity(&StableId(id)).is_some(), "Inventory at StableId {}", id ); } // Interaction Gallery at 24-28 for id in 24..=28 { assert!( registry.to_entity(&StableId(id)).is_some(), "Gallery at StableId {}", id ); } // Pause Chamber at 29 assert!( registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29" ); // Dialogue Room at 30-33 for id in 30..=33 { assert!( registry.to_entity(&StableId(id)).is_some(), "Dialogue Room at StableId {}", id ); } // Crowd Plaza at 34-48 for id in 34..=48 { assert!( registry.to_entity(&StableId(id)).is_some(), "Crowd Plaza at StableId {}", id ); } // Reset plates at 49-55 for id in constants::RESET_PLATE_STABLE_IDS.0..=constants::RESET_PLATE_STABLE_IDS.1 { assert!( registry.to_entity(&StableId(id)).is_some(), "Reset plate at StableId {}", id ); } // Sprint Gauntlet at 56-57 for id in constants::SPRINT_GAUNTLET_STABLE_IDS.0..=constants::SPRINT_GAUNTLET_STABLE_IDS.1 { assert!( registry.to_entity(&StableId(id)).is_some(), "Sprint Gauntlet at StableId {}", id ); } // Eavesdrop Alcove at 58-60 for id in constants::EAVESDROP_ALCOVE_STABLE_IDS.0..=constants::EAVESDROP_ALCOVE_STABLE_IDS.1 { assert!( registry.to_entity(&StableId(id)).is_some(), "Eavesdrop Alcove at StableId {}", id ); } // Confrontation Stage at 61-62 for id in constants::CONFRONTATION_STAGE_STABLE_IDS.0 ..=constants::CONFRONTATION_STAGE_STABLE_IDS.1 { assert!( registry.to_entity(&StableId(id)).is_some(), "Confrontation Stage at StableId {}", id ); } // Sprint 11 reset plates at 63-65 for id in constants::SPRINT11_RESET_PLATE_STABLE_IDS.0 ..=constants::SPRINT11_RESET_PLATE_STABLE_IDS.1 { assert!( registry.to_entity(&StableId(id)).is_some(), "Sprint 11 reset plate at StableId {}", id ); } } }