use std::collections::{HashSet, VecDeque}; use crate::prefab::Room; use crate::tiles::TileType; pub struct GameMap { pub width: i32, pub height: i32, pub tiles: Vec>, pub powered: HashSet<(i32, i32)>, /// Named room rects (spec/schedules.md). Static Act One level data /// derived from the layout's placements, so it is rebuilt rather than /// serialized. pub rooms: Vec, } impl GameMap { /// Build the Act One basement from prefab data (spec/basement-map.md). /// The width/height arguments are ignored; the basement layout fixes them. pub fn new(_width: i32, _height: i32) -> Self { let layout = crate::prefab::basement(); let flat = layout.tiles(); let mut map = Self::from_tiles(layout.width, layout.height, flat, HashSet::new()); map.rooms = layout.rooms; map } /// Reconstruct a GameMap from saved tile data. pub fn from_tiles( width: i32, height: i32, flat_tiles: Vec, powered: HashSet<(i32, i32)>, ) -> Self { let mut tiles = vec![vec![TileType::Rock; width as usize]; height as usize]; for y in 0..height { for x in 0..width { let idx = (y * width + x) as usize; if idx < flat_tiles.len() { tiles[y as usize][x as usize] = flat_tiles[idx]; } } } // Rooms are static level data. When the dimensions match the Act One // basement (the only current plane), rebuild them from the layout so // save/load and reconstruction keep room identity without serializing // it. Custom-sized maps (tests, future planes) start room-less. let rooms = if width == 64 && height == 36 { crate::prefab::basement().rooms } else { Vec::new() }; Self { width, height, tiles, powered, rooms, } } /// The room containing (x, y), if any. pub fn room_at(&self, x: i32, y: i32) -> Option<&Room> { self.rooms.iter().find(|r| r.contains(x, y)) } /// The room with the given prefab name, if present. pub fn room_named(&self, name: &str) -> Option<&Room> { self.rooms.iter().find(|r| r.name == name) } pub fn in_bounds(&self, x: i32, y: i32) -> bool { x >= 0 && x < self.width && y >= 0 && y < self.height } pub fn get_tile(&self, x: i32, y: i32) -> TileType { if self.in_bounds(x, y) { self.tiles[y as usize][x as usize] } else { TileType::Rock } } pub fn set_tile(&mut self, x: i32, y: i32, tile: TileType) { if self.in_bounds(x, y) { self.tiles[y as usize][x as usize] = tile; } } pub fn is_walkable(&self, x: i32, y: i32) -> bool { self.get_tile(x, y).is_walkable() } pub fn core_pos(&self) -> Option<(i32, i32)> { for y in 0..self.height { for x in 0..self.width { if self.tiles[y as usize][x as usize] == TileType::Core { return Some((x, y)); } } } None } pub fn entry_positions(&self) -> Vec<(i32, i32)> { let mut positions = Vec::new(); for y in 0..self.height { for x in 0..self.width { if self.tiles[y as usize][x as usize] == TileType::Entry { positions.push((x, y)); } } } positions } pub fn tiles_of_type(&self, tile_type: TileType) -> Vec<(i32, i32)> { let mut positions = Vec::new(); for y in 0..self.height { for x in 0..self.width { if self.tiles[y as usize][x as usize] == tile_type { positions.push((x, y)); } } } positions } pub fn adjacent_walkable(&self, x: i32, y: i32) -> Vec<(i32, i32)> { let dirs = [(0, -1), (0, 1), (-1, 0), (1, 0)]; dirs.iter() .filter_map(|&(dx, dy)| { let (nx, ny) = (x + dx, y + dy); if self.is_walkable(nx, ny) { Some((nx, ny)) } else { None } }) .collect() } /// Adjacent walkable tiles, considering security bypass level. /// Security doors above the bypass level are treated as impassable. pub fn adjacent_walkable_with_security( &self, x: i32, y: i32, security_bypass: i32, ) -> Vec<(i32, i32)> { let dirs = [(0, -1), (0, 1), (-1, 0), (1, 0)]; dirs.iter() .filter_map(|&(dx, dy)| { let (nx, ny) = (x + dx, y + dy); if !self.is_walkable(nx, ny) { return None; } let tile = self.get_tile(nx, ny); if tile.is_door() && tile.security_level() > security_bypass { return None; // Can't breach this door } Some((nx, ny)) }) .collect() } pub fn find_path( &self, start: (i32, i32), end: (i32, i32), avoid: &HashSet<(i32, i32)>, ) -> Option> { self.find_path_with_security(start, end, avoid, 99) } pub fn find_path_with_security( &self, start: (i32, i32), end: (i32, i32), avoid: &HashSet<(i32, i32)>, security_bypass: i32, ) -> Option> { if start == end { return Some(vec![start]); } let mut queue = VecDeque::new(); queue.push_back(start); let mut came_from: std::collections::HashMap<(i32, i32), Option<(i32, i32)>> = std::collections::HashMap::new(); came_from.insert(start, None); while let Some(current) = queue.pop_front() { for neighbor in self.adjacent_walkable_with_security(current.0, current.1, security_bypass) { if came_from.contains_key(&neighbor) || avoid.contains(&neighbor) { continue; } came_from.insert(neighbor, Some(current)); if neighbor == end { // Reconstruct path let mut path = vec![neighbor]; let mut node = neighbor; while let Some(Some(prev)) = came_from.get(&node) { path.push(*prev); node = *prev; } path.reverse(); return Some(path); } queue.push_back(neighbor); } } None } pub fn update_power(&mut self, power_cores: &[(i32, i32)]) { self.powered.clear(); for &core_pos in power_cores { let mut visited = HashSet::new(); let mut queue = VecDeque::new(); queue.push_back(core_pos); while let Some(pos) = queue.pop_front() { if visited.contains(&pos) { continue; } visited.insert(pos); self.powered.insert(pos); for neighbor in self.adjacent_walkable(pos.0, pos.1) { if !visited.contains(&neighbor) { queue.push_back(neighbor); } } } } } } #[cfg(test)] mod tests { use super::*; use std::collections::HashSet; #[test] fn new_map_has_core() { let map = GameMap::new(80, 40); assert!(map.core_pos().is_some()); } #[test] fn new_map_has_entry() { let map = GameMap::new(80, 40); assert!(!map.entry_positions().is_empty()); } #[test] fn new_map_has_power_core() { let map = GameMap::new(80, 40); let cores = map.tiles_of_type(TileType::PowerCore); assert!(!cores.is_empty(), "Map should have a starting power core"); } /// A blank all-floor map of the given size, bypassing prefab generation, /// for controlled pathfinding/power tests. fn blank(w: i32, h: i32) -> GameMap { GameMap::from_tiles( w, h, vec![TileType::Floor; (w * h) as usize], HashSet::new(), ) } #[test] fn core_connects_to_the_corridor_spine() { // In the authored basement the core bay reaches the corridor system // (through its badge door, bypassed) even though the roll door seals // the dock — connectivity of the interior, not of the whole plane. let map = GameMap::new(0, 0); let core = map.core_pos().unwrap(); let spine = (24, 20); // a corridor-B floor tile assert_eq!(map.get_tile(spine.0, spine.1), TileType::Floor); let path = map.find_path_with_security(core, spine, &HashSet::new(), 3); assert!(path.is_some(), "core reaches the corridor spine"); } #[test] fn find_path_no_path_through_walls() { let mut map = blank(80, 40); for y in 0..40 { for x in 0..80 { map.set_tile(x, y, TileType::Wall); } } map.set_tile(1, 1, TileType::Floor); map.set_tile(70, 35, TileType::Floor); let path = map.find_path((1, 1), (70, 35), &HashSet::new()); assert!(path.is_none(), "No path through solid walls"); } #[test] fn security_pathfinding_respects_doors() { let mut map = blank(80, 40); map.set_tile(40, 20, TileType::SecurityDoor3); let avoid = HashSet::new(); assert!( map.find_path_with_security((35, 20), (45, 20), &avoid, 3) .is_some(), "bypass 3 passes a tier-3 door" ); assert!( map.find_path_with_security((35, 20), (45, 20), &avoid, 1) .is_some(), "bypass 1 routes around it" ); } #[test] fn is_walkable_checks() { let map = GameMap::new(0, 0); let core = map.core_pos().unwrap(); assert!(map.is_walkable(core.0, core.1), "core bay is walkable"); assert!(!map.is_walkable(0, 0), "bedrock corner is not"); } #[test] fn power_propagation_from_core() { let mut map = blank(20, 20); // Isolate a pocket with walls, power core inside. for x in 9..=13 { map.set_tile(x, 8, TileType::Wall); map.set_tile(x, 12, TileType::Wall); } for y in 8..=12 { map.set_tile(9, y, TileType::Wall); map.set_tile(13, y, TileType::Wall); } map.set_tile(10, 10, TileType::PowerCore); map.set_tile(11, 10, TileType::Floor); let cores = map.tiles_of_type(TileType::PowerCore); map.update_power(&cores); assert!(map.powered.contains(&(10, 10))); assert!(map.powered.contains(&(11, 10))); } #[test] fn power_blocked_by_wall() { let mut map = blank(20, 20); // A wall ring around a power core; floor outside must stay dark. for x in 9..=11 { map.set_tile(x, 9, TileType::Wall); map.set_tile(x, 11, TileType::Wall); } map.set_tile(9, 10, TileType::Wall); map.set_tile(11, 10, TileType::Wall); map.set_tile(10, 10, TileType::PowerCore); let cores = map.tiles_of_type(TileType::PowerCore); map.update_power(&cores); assert!(map.powered.contains(&(10, 10))); assert!( !map.powered.contains(&(13, 10)), "power does not cross the wall ring" ); } }