//! Gauntlet room and entity constants — single source of truth. //! //! All Gauntlet tests and room builders reference these constants instead //! of magic numbers. Room geometry, observer positions, entity placements, //! and StableId ranges are defined here. //! //! Canonical spawn order determines StableId assignment. Do NOT reorder //! existing entries — append new rooms/entities at the end (additive-only). //! //! Specification source: gestalt-round3.md Section 7. use crate::bridge::types::FacingDirection; use crate::perception::vision_cone::Facing; use crate::simulation::movement::TilePosition; /// A Gauntlet room definition. #[derive(Debug, Clone)] pub struct GauntletRoom { /// Room identifier string (e.g., "central_hub", "occlusion_corridor"). pub name: &'static str, /// Top-left corner of the room (includes walls). pub origin: TilePosition, /// (width, height) in sim tiles (includes walls). pub size: (i32, i32), /// Player spawn position within this room (absolute coordinates). pub spawn: TilePosition, /// Golden file observer position (absolute coordinates). pub observer: TilePosition, /// Direction the observer faces for golden file snapshots. pub observer_facing: Facing, /// Reset plate location (if any). Only present in rooms with /// corridor entrances (not the hub). pub reset_plate: Option, } /// A Gauntlet entity definition. #[derive(Debug, Clone)] pub struct GauntletEntity { /// Entity name (e.g., "npc_guard_visible", "crate_01"). pub name: &'static str, /// Which room this entity belongs to. pub room: &'static str, /// Absolute position in the Gauntlet map. pub position: TilePosition, /// Entity kind for classification. pub kind: EntityKind, /// StableId assigned to this entity. pub stable_id: u64, } /// Entity classification for Gauntlet test entities. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EntityKind { Sign, Npc, Object, } // ============================================================ // Room constants — canonical spawn order // ============================================================ pub const HUB: GauntletRoom = GauntletRoom { name: "central_hub", origin: TilePosition { x: 38, y: 46, z: 0 }, size: (24, 24), spawn: TilePosition { x: 50, y: 58, z: 0 }, observer: TilePosition { x: 50, y: 58, z: 0 }, observer_facing: Facing(FacingDirection::North), reset_plate: None, }; pub const FOG_THEATER: GauntletRoom = GauntletRoom { name: "fog_theater", origin: TilePosition { x: 28, y: 2, z: 0 }, size: (44, 32), spawn: TilePosition { x: 56, y: 18, z: 0 }, observer: TilePosition { x: 56, y: 18, z: 0 }, observer_facing: Facing(FacingDirection::South), reset_plate: Some(TilePosition { x: 50, y: 34, z: 0 }), }; pub const OCCLUSION_CORRIDOR: GauntletRoom = GauntletRoom { name: "occlusion_corridor", origin: TilePosition { x: 74, y: 48, z: 0 }, size: (42, 22), spawn: TilePosition { x: 84, y: 58, z: 0 }, observer: TilePosition { x: 84, y: 58, z: 0 }, observer_facing: Facing(FacingDirection::East), reset_plate: Some(TilePosition { x: 73, y: 58, z: 0 }), }; pub const INVENTORY_WAREHOUSE: GauntletRoom = GauntletRoom { name: "inventory_warehouse", origin: TilePosition { x: 2, y: 40, z: 0 }, size: (30, 28), spawn: TilePosition { x: 17, y: 54, z: 0 }, observer: TilePosition { x: 17, y: 54, z: 0 }, observer_facing: Facing(FacingDirection::East), reset_plate: Some(TilePosition { x: 33, y: 58, z: 0 }), }; pub const INTERACTION_GALLERY: GauntletRoom = GauntletRoom { name: "interaction_gallery", origin: TilePosition { x: 2, y: 82, z: 0 }, size: (24, 20), spawn: TilePosition { x: 14, y: 92, z: 0 }, observer: TilePosition { x: 14, y: 92, z: 0 }, observer_facing: Facing(FacingDirection::East), reset_plate: Some(TilePosition { x: 14, y: 82, z: 0 }), }; pub const PAUSE_CHAMBER: GauntletRoom = GauntletRoom { name: "pause_chamber", origin: TilePosition { x: 42, y: 78, z: 0 }, size: (16, 16), spawn: TilePosition { x: 50, y: 86, z: 0 }, observer: TilePosition { x: 50, y: 86, z: 0 }, observer_facing: Facing(FacingDirection::North), reset_plate: Some(TilePosition { x: 50, y: 77, z: 0 }), }; pub const DIALOGUE_ROOM: GauntletRoom = GauntletRoom { name: "dialogue_room", origin: TilePosition { x: 36, y: 104, z: 0, }, size: (28, 20), spawn: TilePosition { x: 50, y: 114, z: 0, }, observer: TilePosition { x: 50, y: 114, z: 0, }, observer_facing: Facing(FacingDirection::North), reset_plate: Some(TilePosition { x: 50, y: 103, z: 0, }), }; pub const CROWD_PLAZA: GauntletRoom = GauntletRoom { name: "crowd_plaza", origin: TilePosition { x: 80, y: 78, z: 0 }, size: (32, 32), spawn: TilePosition { x: 96, y: 94, z: 0 }, observer: TilePosition { x: 96, y: 94, z: 0 }, observer_facing: Facing(FacingDirection::West), reset_plate: Some(TilePosition { x: 80, y: 86, z: 0 }), }; /// Sprint Gauntlet — Room 8 (28x20) /// Tests D-055 sprint suppression. Player sprints past a visible NPC; /// interaction buffer must be empty during sprint, anomaly monologue fires /// retroactively after sprint ends. pub const SPRINT_GAUNTLET: GauntletRoom = GauntletRoom { name: "sprint_gauntlet", origin: TilePosition { x: 0, y: 2, z: 0 }, size: (28, 20), spawn: TilePosition { x: 4, y: 10, z: 0 }, observer: TilePosition { x: 4, y: 10, z: 0 }, observer_facing: Facing(FacingDirection::East), reset_plate: Some(TilePosition { x: 14, y: 22, z: 0 }), }; /// Eavesdrop Alcove — Room 9 (24x16) /// Tests eavesdrop positioning and ListeningFocus (D-071). Player in Careful /// stance at a corner within audible range of two NPCs in conversation. pub const EAVESDROP_ALCOVE: GauntletRoom = GauntletRoom { name: "eavesdrop_alcove", origin: TilePosition { x: 74, y: 26, z: 0 }, size: (24, 16), spawn: TilePosition { x: 78, y: 36, z: 0 }, observer: TilePosition { x: 78, y: 36, z: 0 }, observer_facing: Facing(FacingDirection::East), reset_plate: Some(TilePosition { x: 86, y: 42, z: 0 }), }; /// Confrontation Stage — Room 10 (32x24) /// Tests D-070 confrontation vulnerability. Peripheral NPC movement during /// confrontation is suppressed; post-confrontation delayed monologue fires. pub const CONFRONTATION_STAGE: GauntletRoom = GauntletRoom { name: "confrontation_stage", origin: TilePosition { x: 84, y: 2, z: 0 }, size: (32, 24), spawn: TilePosition { x: 94, y: 20, z: 0 }, observer: TilePosition { x: 94, y: 20, z: 0 }, observer_facing: Facing(FacingDirection::North), reset_plate: Some(TilePosition { x: 100, y: 26, z: 0, }), }; /// Sound Lab — Room 12 (34x20) /// Tests D-018 (three-range sound model). Sound-emitting NPCs at calibrated /// distances from the observer: Close (2 tiles), Medium (6 tiles), Long (12 tiles). pub const SOUND_LAB: GauntletRoom = GauntletRoom { name: "sound_lab", origin: TilePosition { x: 0, y: 104, z: 0 }, size: (34, 20), spawn: TilePosition { x: 10, y: 114, z: 0, }, observer: TilePosition { x: 10, y: 114, z: 0, }, observer_facing: Facing(FacingDirection::East), reset_plate: Some(TilePosition { x: 16, y: 103, z: 0, }), }; /// Decay Observatory — Room 13 (24x14) /// Tests D-041 knowledge graph decay. NPC observed in LOS then LOS broken; /// confidence degrades Direct → KnowsDetails → KnowsOf → Suspects over ticks. pub const DECAY_OBSERVATORY: GauntletRoom = GauntletRoom { name: "decay_observatory", origin: TilePosition { x: 0, y: 24, z: 0 }, size: (24, 14), spawn: TilePosition { x: 10, y: 30, z: 0 }, observer: TilePosition { x: 10, y: 30, z: 0 }, observer_facing: Facing(FacingDirection::East), reset_plate: Some(TilePosition { x: 15, y: 23, z: 0 }), }; /// Shift Change — Room 14 (16x24) /// Tests D-031 day-phase transitions. Two NPCs with DailyRoutine components /// receive PathRequests when game clock crosses a phase boundary. pub const SHIFT_CHANGE: GauntletRoom = GauntletRoom { name: "shift_change", origin: TilePosition { x: 64, y: 78, z: 0 }, size: (16, 24), spawn: TilePosition { x: 72, y: 90, z: 0 }, observer: TilePosition { x: 72, y: 90, z: 0 }, observer_facing: Facing(FacingDirection::North), 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. pub const ROOMS: &[GauntletRoom] = &[ HUB, FOG_THEATER, OCCLUSION_CORRIDOR, INVENTORY_WAREHOUSE, INTERACTION_GALLERY, PAUSE_CHAMBER, DIALOGUE_ROOM, CROWD_PLAZA, SPRINT_GAUNTLET, EAVESDROP_ALCOVE, CONFRONTATION_STAGE, SOUND_LAB, DECAY_OBSERVATORY, SHIFT_CHANGE, ZONE_GATE, ]; /// Look up which room a position falls in. /// Returns the first room whose bounding box contains the position. /// /// Assumptions: /// - All rooms are at z=0 (single-floor Gauntlet). Positions on other /// z-levels will never match. /// - Room bounding boxes must not overlap (verified by `rooms_do_not_overlap` /// test). Corridors are NOT rooms and intentionally fall outside all /// bounding boxes — `room_at` returns None for corridor positions. pub fn room_at(pos: &TilePosition) -> Option<&'static GauntletRoom> { ROOMS.iter().find(|r| { pos.x >= r.origin.x && pos.x < r.origin.x + r.size.0 && pos.y >= r.origin.y && pos.y < r.origin.y + r.size.1 && pos.z == r.origin.z }) } // ============================================================ // StableId range constants // ============================================================ /// Player entity always gets StableId 0. pub const PLAYER_STABLE_ID: u64 = 0; /// StableId ranges per room (start, end inclusive). pub const HUB_STABLE_IDS: (u64, u64) = (1, 4); pub const FOG_THEATER_STABLE_IDS: (u64, u64) = (5, 8); pub const OCCLUSION_STABLE_IDS: (u64, u64) = (9, 12); pub const INVENTORY_STABLE_IDS: (u64, u64) = (13, 23); pub const INTERACTION_GALLERY_STABLE_IDS: (u64, u64) = (24, 28); pub const PAUSE_CHAMBER_STABLE_IDS: (u64, u64) = (29, 29); pub const DIALOGUE_ROOM_STABLE_IDS: (u64, u64) = (30, 33); pub const CROWD_PLAZA_STABLE_IDS: (u64, u64) = (34, 48); pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 55); // Sprint 11 rooms — appended after RESET_PLATE_STABLE_IDS per additive-only rule. pub const SPRINT_GAUNTLET_STABLE_IDS: (u64, u64) = (56, 57); pub const EAVESDROP_ALCOVE_STABLE_IDS: (u64, u64) = (58, 60); pub const CONFRONTATION_STAGE_STABLE_IDS: (u64, u64) = (61, 62); /// Reset plates for Sprint 11 rooms (sprint_gauntlet, eavesdrop_alcove, confrontation_stage). pub const SPRINT11_RESET_PLATE_STABLE_IDS: (u64, u64) = (63, 65); // Sprint 13 rooms — appended after SPRINT11_RESET_PLATE_STABLE_IDS per additive-only rule. pub const SOUND_LAB_STABLE_IDS: (u64, u64) = (66, 68); 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. pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0) + (HUB_STABLE_IDS.1 - HUB_STABLE_IDS.0 + 1) as usize + (FOG_THEATER_STABLE_IDS.1 - FOG_THEATER_STABLE_IDS.0 + 1) as usize + (OCCLUSION_STABLE_IDS.1 - OCCLUSION_STABLE_IDS.0 + 1) as usize + (INVENTORY_STABLE_IDS.1 - INVENTORY_STABLE_IDS.0 + 1) as usize + (INTERACTION_GALLERY_STABLE_IDS.1 - INTERACTION_GALLERY_STABLE_IDS.0 + 1) as usize + (PAUSE_CHAMBER_STABLE_IDS.1 - PAUSE_CHAMBER_STABLE_IDS.0 + 1) as usize + (DIALOGUE_ROOM_STABLE_IDS.1 - DIALOGUE_ROOM_STABLE_IDS.0 + 1) as usize + (CROWD_PLAZA_STABLE_IDS.1 - CROWD_PLAZA_STABLE_IDS.0 + 1) as usize + (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize + (SPRINT_GAUNTLET_STABLE_IDS.1 - SPRINT_GAUNTLET_STABLE_IDS.0 + 1) as usize + (EAVESDROP_ALCOVE_STABLE_IDS.1 - EAVESDROP_ALCOVE_STABLE_IDS.0 + 1) as usize + (CONFRONTATION_STAGE_STABLE_IDS.1 - CONFRONTATION_STAGE_STABLE_IDS.0 + 1) as usize + (SPRINT11_RESET_PLATE_STABLE_IDS.1 - SPRINT11_RESET_PLATE_STABLE_IDS.0 + 1) as usize + (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 + (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 { use super::*; #[test] fn room_at_finds_hub() { let pos = TilePosition { x: 50, y: 58, z: 0 }; let room = room_at(&pos).expect("Hub center should be in a room"); assert_eq!(room.name, "central_hub"); } #[test] fn room_at_finds_occlusion_corridor() { let pos = TilePosition { x: 84, y: 58, z: 0 }; let room = room_at(&pos).expect("Occlusion observer should be in a room"); assert_eq!(room.name, "occlusion_corridor"); } #[test] fn room_at_finds_inventory_warehouse() { let pos = TilePosition { x: 17, y: 54, z: 0 }; let room = room_at(&pos).expect("Inventory observer should be in a room"); assert_eq!(room.name, "inventory_warehouse"); } #[test] fn room_at_finds_pause_chamber() { let pos = TilePosition { x: 50, y: 86, z: 0 }; let room = room_at(&pos).expect("Pause observer should be in a room"); assert_eq!(room.name, "pause_chamber"); } #[test] fn room_at_finds_fog_theater() { let pos = TilePosition { x: 56, y: 18, z: 0 }; let room = room_at(&pos).expect("Fog Theater observer should be in a room"); assert_eq!(room.name, "fog_theater"); } #[test] fn room_at_finds_interaction_gallery() { let pos = TilePosition { x: 14, y: 92, z: 0 }; let room = room_at(&pos).expect("Interaction Gallery observer should be in a room"); assert_eq!(room.name, "interaction_gallery"); } #[test] fn room_at_finds_dialogue_room() { let pos = TilePosition { x: 50, y: 114, z: 0, }; let room = room_at(&pos).expect("Dialogue Room observer should be in a room"); assert_eq!(room.name, "dialogue_room"); } #[test] fn room_at_finds_crowd_plaza() { let pos = TilePosition { x: 96, y: 94, z: 0 }; let room = room_at(&pos).expect("Crowd Plaza observer should be in a room"); assert_eq!(room.name, "crowd_plaza"); } #[test] fn room_at_finds_sprint_gauntlet() { let pos = TilePosition { x: 4, y: 10, z: 0 }; let room = room_at(&pos).expect("Sprint Gauntlet observer should be in a room"); assert_eq!(room.name, "sprint_gauntlet"); } #[test] fn room_at_finds_eavesdrop_alcove() { let pos = TilePosition { x: 78, y: 36, z: 0 }; let room = room_at(&pos).expect("Eavesdrop Alcove observer should be in a room"); assert_eq!(room.name, "eavesdrop_alcove"); } #[test] fn room_at_finds_confrontation_stage() { let pos = TilePosition { x: 94, y: 20, z: 0 }; let room = room_at(&pos).expect("Confrontation Stage observer should be in a room"); assert_eq!(room.name, "confrontation_stage"); } #[test] fn room_at_returns_none_for_corridor() { // Point inside corridor-E (between Hub and Occlusion) let pos = TilePosition { x: 66, y: 57, z: 0 }; assert!( room_at(&pos).is_none(), "Corridor should not be in any room" ); } #[test] fn room_at_returns_none_for_outside_map() { let pos = TilePosition { x: 200, y: 200, z: 0, }; assert!(room_at(&pos).is_none()); } #[test] fn all_rooms_in_correct_order() { 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"); assert_eq!(ROOMS[3].name, "inventory_warehouse"); assert_eq!(ROOMS[4].name, "interaction_gallery"); assert_eq!(ROOMS[5].name, "pause_chamber"); assert_eq!(ROOMS[6].name, "dialogue_room"); assert_eq!(ROOMS[7].name, "crowd_plaza"); assert_eq!(ROOMS[8].name, "sprint_gauntlet"); assert_eq!(ROOMS[9].name, "eavesdrop_alcove"); assert_eq!(ROOMS[10].name, "confrontation_stage"); 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] fn rooms_do_not_overlap() { for (i, a) in ROOMS.iter().enumerate() { for (j, b) in ROOMS.iter().enumerate() { if i >= j { continue; } let overlap_x = a.origin.x < b.origin.x + b.size.0 && a.origin.x + a.size.0 > b.origin.x; let overlap_y = a.origin.y < b.origin.y + b.size.1 && a.origin.y + a.size.1 > b.origin.y; assert!( !(overlap_x && overlap_y), "Rooms {} and {} overlap", a.name, b.name ); } } } #[test] fn observer_inside_room() { for room in ROOMS { let obs = &room.observer; assert!( obs.x >= room.origin.x && obs.x < room.origin.x + room.size.0, "Observer x={} outside room {} (origin.x={}, width={})", obs.x, room.name, room.origin.x, room.size.0 ); assert!( obs.y >= room.origin.y && obs.y < room.origin.y + room.size.1, "Observer y={} outside room {} (origin.y={}, height={})", obs.y, room.name, room.origin.y, room.size.1 ); } } #[test] fn stable_id_ranges_non_overlapping() { let ranges = [ HUB_STABLE_IDS, FOG_THEATER_STABLE_IDS, OCCLUSION_STABLE_IDS, INVENTORY_STABLE_IDS, INTERACTION_GALLERY_STABLE_IDS, PAUSE_CHAMBER_STABLE_IDS, DIALOGUE_ROOM_STABLE_IDS, CROWD_PLAZA_STABLE_IDS, RESET_PLATE_STABLE_IDS, SPRINT_GAUNTLET_STABLE_IDS, EAVESDROP_ALCOVE_STABLE_IDS, CONFRONTATION_STAGE_STABLE_IDS, SPRINT11_RESET_PLATE_STABLE_IDS, SOUND_LAB_STABLE_IDS, 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() { if i >= j { continue; } assert!( a.1 < b.0 || b.1 < a.0, "StableId ranges {} and {} overlap: {:?} vs {:?}", i, j, a, b ); } } } }