//! 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 + Sprint 13 + Sprint 22): //! 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 //! Sound Lab: 66-68 //! 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; #[cfg(feature = "gauntlet")] pub mod invariants; 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::contraband::ScanEventBuffer; #[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; 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 crate::simulation::zone::ZoneMap; #[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 // Sprint 13 rooms 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 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); // 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, room.origin.y, room.size.0, room.size.1, room.origin.z, 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. // 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(), crate::simulation::pressure::CharacterPressure::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); // --- Sound Lab (StableId 66-68) --- rooms::sound_lab::spawn_entities(app, &mut registry); // --- Decay Observatory (StableId 69) --- rooms::decay_observatory::spawn_entities(app, &mut registry); // --- Shift Change (StableId 70-71) --- rooms::shift_change::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)); } // --- 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", constants::SOUND_LAB .reset_plate .expect("sound_lab should have a reset_plate"), ), ( "decay_observatory", constants::DECAY_OBSERVATORY .reset_plate .expect("decay_observatory should have a reset_plate"), ), ( "shift_change", constants::SHIFT_CHANGE .reset_plate .expect("shift_change should have a reset_plate"), ), ]; for &(room_name, pos) in sprint13_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)); } // --- 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(); // 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); } } } // Sound Lab entities (StableId 66-68): NPCs only, no floor items for id in constants::SOUND_LAB_STABLE_IDS.0..=constants::SOUND_LAB_STABLE_IDS.1 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("sound_lab", entity, *pos, false); } } } // Decay Observatory entities (StableId 69): NPC only, no floor items for id in constants::DECAY_OBSERVATORY_STABLE_IDS.0..=constants::DECAY_OBSERVATORY_STABLE_IDS.1 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("decay_observatory", entity, *pos, false); } } } // Shift Change entities (StableId 70-71): NPCs only, no floor items for id in constants::SHIFT_CHANGE_STABLE_IDS.0..=constants::SHIFT_CHANGE_STABLE_IDS.1 { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("shift_change", entity, *pos, false); } } } // 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::(entity) { snapshots.record("zone_gate", entity, *pos, false); } } } app.insert_resource(snapshots); // --- Sprint 14 component fixup --- // Attach MoodState and InteractionMemory to all Npc entities that are // missing them. Gauntlet room builders don't include these yet — this // ensures invariant S14-1/S14-2 pass and the mood/trust systems have // valid component targets. { use crate::npc::interaction::InteractionMemory; use crate::npc::mood::MoodState; use crate::npc::Npc; let missing_mood: Vec = { let mut q = app .world_mut() .query_filtered::, bevy_ecs::prelude::Without, )>(); q.iter(app.world()).collect() }; for entity in missing_mood { app.world_mut() .entity_mut(entity) .insert(MoodState::default()); } let missing_mem: Vec = { let mut q = app .world_mut() .query_filtered::, bevy_ecs::prelude::Without, )>(); q.iter(app.world()).collect() }; for entity in missing_mem { app.world_mut() .entity_mut(entity) .insert(InteractionMemory::default()); } } // --- Dialogue fixup: wire up DialogueProfile on all Npc entities missing one --- // Gauntlet room builders (except dialogue_room) don't include DialogueProfile. // Without it, the Talk verb silently no-ops. This fixup ensures every NPC // can respond to Talk using content from the YAML dialogue pools. { use crate::npc::Npc; use crate::simulation::conversation::NpcColorIndex; use crate::simulation::dialogue::{CurrentMood, DialogueProfile}; // (location, role) pairs matching content/campaigns/.../dialogue/ YAML pools. // Cycling through these gives NPC variety across rooms. const DIALOGUE_ROLES: &[(&str, &str)] = &[ ("the-terminal", "dock-worker"), ("the-terminal", "courier"), ("the-terminal", "maintenance-tech"), ("the-terminal", "new-hire"), ("the-terminal", "scheduler"), ("the-terminal", "shift-supervisor"), ("the-last-shift", "bartender"), ("the-last-shift", "bar-regular"), ("the-last-shift", "day-worker"), ("maintenance-corridors", "transit-worker"), ]; let missing: Vec = { let mut q = app .world_mut() .query_filtered::, bevy_ecs::prelude::Without, )>(); q.iter(app.world()).collect() }; for (i, entity) in missing.iter().enumerate() { let (location, role) = DIALOGUE_ROLES[i % DIALOGUE_ROLES.len()]; let color_index = registry .to_stable(*entity) .map(|sid| (sid.0 % 8) as u8) .unwrap_or(0u8); app.world_mut().entity_mut(*entity).insert(( DialogueProfile { location: location.to_string(), role: role.to_string(), }, CurrentMood::default(), NpcColorIndex(color_index), )); } } 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); // Run all 29 world-query invariants against the fully-initialized gauntlet world. invariants::run_invariants(app.world_mut()); 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 ); } // Sound Lab at 66-68 for id in constants::SOUND_LAB_STABLE_IDS.0..=constants::SOUND_LAB_STABLE_IDS.1 { assert!( registry.to_entity(&StableId(id)).is_some(), "Sound Lab at StableId {}", id ); } // Decay Observatory at 69 for id in constants::DECAY_OBSERVATORY_STABLE_IDS.0 ..=constants::DECAY_OBSERVATORY_STABLE_IDS.1 { assert!( registry.to_entity(&StableId(id)).is_some(), "Decay Observatory at StableId {}", id ); } // Shift Change at 70-71 for id in constants::SHIFT_CHANGE_STABLE_IDS.0..=constants::SHIFT_CHANGE_STABLE_IDS.1 { assert!( registry.to_entity(&StableId(id)).is_some(), "Shift Change at StableId {}", id ); } // Sprint 13 reset plates at 72-74 for id in constants::SPRINT13_RESET_PLATE_STABLE_IDS.0 ..=constants::SPRINT13_RESET_PLATE_STABLE_IDS.1 { assert!( registry.to_entity(&StableId(id)).is_some(), "Sprint 13 reset plate at StableId {}", 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 ); } } }