diff --git a/Cargo.lock b/Cargo.lock index 4030cea..deda24e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,7 +148,7 @@ dependencies = [ "ndk-context", "ndk-sys 0.6.0+11769913", "num_enum", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -341,7 +341,7 @@ dependencies = [ "petgraph", "ron", "serde", - "thiserror", + "thiserror 1.0.68", "thread_local", "uuid", ] @@ -359,7 +359,7 @@ dependencies = [ "bevy_utils", "console_error_panic_hook", "downcast-rs", - "thiserror", + "thiserror 1.0.68", "wasm-bindgen", "web-sys", ] @@ -389,7 +389,7 @@ dependencies = [ "parking_lot", "ron", "serde", - "thiserror", + "thiserror 1.0.68", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -438,7 +438,7 @@ dependencies = [ "bytemuck", "encase", "serde", - "thiserror", + "thiserror 1.0.68", "wgpu-types", ] @@ -478,7 +478,7 @@ dependencies = [ "radsort", "serde", "smallvec", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -526,7 +526,7 @@ dependencies = [ "nonmax", "petgraph", "serde", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -563,7 +563,7 @@ dependencies = [ "bevy_time", "bevy_utils", "gilrs", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -629,7 +629,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -658,7 +658,7 @@ dependencies = [ "bevy_reflect", "bevy_utils", "smol_str", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -740,7 +740,7 @@ dependencies = [ "rand", "serde", "smallvec", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -801,7 +801,7 @@ dependencies = [ "serde", "smallvec", "smol_str", - "thiserror", + "thiserror 1.0.68", "uuid", ] @@ -860,7 +860,7 @@ dependencies = [ "send_wrapper", "serde", "smallvec", - "thiserror", + "thiserror 1.0.68", "wasm-bindgen", "web-sys", "wgpu", @@ -894,7 +894,7 @@ dependencies = [ "bevy_transform", "bevy_utils", "serde", - "thiserror", + "thiserror 1.0.68", "uuid", ] @@ -921,7 +921,7 @@ dependencies = [ "guillotiere", "radsort", "rectangle-pack", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -983,7 +983,7 @@ dependencies = [ "bevy_window", "glyph_brush_layout", "serde", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -997,7 +997,7 @@ dependencies = [ "bevy_reflect", "bevy_utils", "crossbeam-channel", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -1011,7 +1011,7 @@ dependencies = [ "bevy_hierarchy", "bevy_math", "bevy_reflect", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -1041,7 +1041,7 @@ dependencies = [ "nonmax", "smallvec", "taffy", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -1258,7 +1258,7 @@ dependencies = [ "polling", "rustix", "slab", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -1606,7 +1606,7 @@ dependencies = [ "const_panic", "encase_derive", "glam", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -1966,7 +1966,7 @@ checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" dependencies = [ "log", "presser", - "thiserror", + "thiserror 1.0.68", "winapi", "windows 0.52.0", ] @@ -2034,7 +2034,7 @@ dependencies = [ "com", "libc", "libloading 0.8.5", - "thiserror", + "thiserror 1.0.68", "widestring", "winapi", ] @@ -2063,13 +2063,14 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] name = "hexlab" -version = "0.1.0" +version = "0.1.1" dependencies = [ "bevy", "hexx", "rand", "rand_chacha", "serde", + "thiserror 2.0.3", ] [[package]] @@ -2176,7 +2177,7 @@ dependencies = [ "combine", "jni-sys", "log", - "thiserror", + "thiserror 1.0.68", "walkdir", "windows-sys 0.45.0", ] @@ -2405,7 +2406,7 @@ dependencies = [ "rustc-hash", "spirv", "termcolor", - "thiserror", + "thiserror 1.0.68", "unicode-xid", ] @@ -2424,7 +2425,7 @@ dependencies = [ "regex", "regex-syntax 0.8.5", "rustc-hash", - "thiserror", + "thiserror 1.0.68", "tracing", "unicode-ident", ] @@ -2440,7 +2441,7 @@ dependencies = [ "log", "ndk-sys 0.5.0+25.2.9519653", "num_enum", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -2455,7 +2456,7 @@ dependencies = [ "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -3156,7 +3157,7 @@ checksum = "d1fceb9d127d515af1586d8d0cc601e1245bdb0af38e75c865a156290184f5b3" dependencies = [ "cpal", "lewton", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -3397,7 +3398,16 @@ version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.68", +] + +[[package]] +name = "thiserror" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa" +dependencies = [ + "thiserror-impl 2.0.3", ] [[package]] @@ -3411,6 +3421,17 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "thiserror-impl" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -3749,7 +3770,7 @@ dependencies = [ "raw-window-handle", "rustc-hash", "smallvec", - "thiserror", + "thiserror 1.0.68", "web-sys", "wgpu-hal", "wgpu-types", @@ -3793,7 +3814,7 @@ dependencies = [ "renderdoc-sys", "rustc-hash", "smallvec", - "thiserror", + "thiserror 1.0.68", "wasm-bindgen", "web-sys", "wgpu-types", diff --git a/Cargo.toml b/Cargo.toml index 44c1b25..de1c3ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "hexlab" authors = ["Kristofers Solo "] -version = "0.1.0" +version = "0.1.1" edition = "2021" description = "A hexagonal maze library" repository = "https://github.com/kristoferssolo/hexlab" @@ -14,6 +14,7 @@ hexx = { version = "0.18" } rand = "0.8" rand_chacha = "0.3" serde = { version = "1.0", features = ["derive"], optional = true } +thiserror = "2.0" [features] default = [] diff --git a/src/builder.rs b/src/builder.rs index 9e9ab81..b61b600 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,10 +1,29 @@ -use std::collections::HashSet; +use hexx::Hex; +use thiserror::Error; -use hexx::{EdgeDirection, Hex}; -use rand::{seq::SliceRandom, thread_rng, Rng, SeedableRng}; -use rand_chacha::ChaCha8Rng; +use crate::{ + generator::{generate_backtracking, GeneratorType}, + HexMaze, +}; -use crate::{generator::GeneratorType, HexMaze}; +#[derive(Debug, Error)] +pub enum MazeBuilderError { + /// Occurs when attempting to build a maze without specifying a radius. + #[error("Radius must be specified to build a maze")] + NoRadius, + + /// Occurs when the specified radius is too large. + #[error("Radius {0} is too large. Maximum allowed radius is {1}")] + RadiusTooLarge(u32, u32), + + /// Occurs when the specified start position is outside the maze bounds. + #[error("Start position {0:?} is outside maze bounds")] + InvalidStartPosition(Hex), + + /// Occurs when maze generation fails. + #[error("Failed to generate maze: {0}")] + GenerationError(String), +} /// A builder pattern for creating hexagonal mazes. /// @@ -118,7 +137,8 @@ impl MazeBuilder { /// /// # Errors /// - /// Returns an error if no radius is specified. + /// Returns [`MazeBuilderError::NoRadius`] if no radius is specified. + /// Returns [`MazeBuilderError::InvalidStartPosition`] if the start position is outside maze bounds. /// /// # Examples /// @@ -138,10 +158,16 @@ impl MazeBuilder { /// let maze = result.unwrap(); /// assert!(!maze.is_empty()); /// ``` - pub fn build(self) -> Result { - let radius = self.radius.ok_or("Radius must be specified")?; + pub fn build(self) -> Result { + let radius = self.radius.ok_or(MazeBuilderError::NoRadius)?; let mut maze = self.create_hex_maze(radius); + if let Some(start_pos) = self.start_position { + if maze.get_tile(&start_pos).is_none() { + return Err(MazeBuilderError::InvalidStartPosition(start_pos)); + } + } + if !maze.is_empty() { self.generate_maze(&mut maze); } @@ -165,49 +191,203 @@ impl MazeBuilder { } fn generate_maze(&self, maze: &mut HexMaze) { - match self.seed { - Some(seed) => self.generate_from_seed(maze, seed), - None => self.generate_backtracking(maze), - } - } - - fn generate_from_seed(&self, maze: &mut HexMaze, seed: u64) { - if maze.is_empty() { - return; - } - let start = Hex::ZERO; - let mut visited = HashSet::new(); - let mut rng = ChaCha8Rng::seed_from_u64(seed); - self.recursive_backtrack(maze, start, &mut visited, &mut rng); - } - - fn generate_backtracking(&self, maze: &mut HexMaze) { - if maze.is_empty() { - return; - } - let start = *maze.keys().next().unwrap(); - let mut visited = HashSet::new(); - let mut rng = thread_rng(); - self.recursive_backtrack(maze, start, &mut visited, &mut rng); - } - - fn recursive_backtrack( - &self, - maze: &mut HexMaze, - current: Hex, - visited: &mut HashSet, - rng: &mut R, - ) { - visited.insert(current); - let mut directions = EdgeDirection::ALL_DIRECTIONS; - directions.shuffle(rng); - - for direction in directions { - let neighbor = current + direction; - if maze.get_tile(&neighbor).is_some() && !visited.contains(&neighbor) { - maze.remove_tile_wall(¤t, direction); - maze.remove_tile_wall(&neighbor, direction.const_neg()); - self.recursive_backtrack(maze, neighbor, visited, rng); + match self.generator_type { + GeneratorType::RecursiveBacktracking => { + generate_backtracking(maze, self.start_position, self.seed) + } + } + } +} + +#[cfg(test)] +mod test { + use hexx::EdgeDirection; + + use super::*; + + /// Helper function to count the number of tiles for a given radius + fn calculate_hex_tiles(radius: u32) -> usize { + let r = radius as i32; + (3 * r * r + 3 * r + 1) as usize + } + + #[test] + fn new_builder() { + let builder = MazeBuilder::new(); + assert!(builder.radius.is_none()); + assert!(builder.seed.is_none()); + assert!(builder.start_position.is_none()); + } + + #[test] + fn builder_with_radius() { + let radius = 5; + let maze = MazeBuilder::new().with_radius(radius).build().unwrap(); + + assert_eq!(maze.len(), calculate_hex_tiles(radius)); + assert!(maze.get_tile(&Hex::ZERO).is_some()); + } + + #[test] + fn builder_without_radius() { + let maze = MazeBuilder::new().build(); + assert!(matches!(maze, Err(MazeBuilderError::NoRadius))); + } + + #[test] + fn builder_with_seed() { + let radius = 3; + let seed = 12345; + + let maze1 = MazeBuilder::new() + .with_radius(radius) + .with_seed(seed) + .build() + .unwrap(); + + let maze2 = MazeBuilder::new() + .with_radius(radius) + .with_seed(seed) + .build() + .unwrap(); + + // Same seed should produce identical mazes + assert_eq!(maze1, maze2); + } + + #[test] + fn different_seeds_produce_different_mazes() { + let radius = 3; + + let maze1 = MazeBuilder::new() + .with_radius(radius) + .with_seed(12345) + .build() + .unwrap(); + + let maze2 = MazeBuilder::new() + .with_radius(radius) + .with_seed(54321) + .build() + .unwrap(); + + // Different seeds should produce different mazes + assert_ne!(maze1, maze2); + } + + #[test] + fn maze_connectivity() { + let radius = 3; + let maze = MazeBuilder::new().with_radius(radius).build().unwrap(); + + // Helper function to count accessible neighbors + fn count_accessible_neighbors(maze: &HexMaze, pos: Hex) -> usize { + EdgeDirection::ALL_DIRECTIONS + .iter() + .filter(|&&dir| { + let neighbor = pos + dir; + if let Some(walls) = maze.get_walls(&pos) { + !walls.contains(dir) && maze.get_tile(&neighbor).is_some() + } else { + false + } + }) + .count() + } + + // Check that each tile has at least one connection + for &pos in maze.keys() { + let accessible_neighbors = count_accessible_neighbors(&maze, pos); + assert!( + accessible_neighbors > 0, + "Tile at {:?} has no accessible neighbors", + pos + ); + } + } + + #[test] + fn start_position() { + let radius = 3; + let start_pos = Hex::new(1, 1); + + let maze = MazeBuilder::new() + .with_radius(radius) + .with_start_position(start_pos) + .build() + .unwrap(); + + assert!(maze.get_tile(&start_pos).is_some()); + } + + #[test] + fn invalid_start_position() { + let maze = MazeBuilder::new() + .with_radius(3) + .with_start_position(Hex::new(10, 10)) + .build(); + + assert!(matches!( + maze, + Err(MazeBuilderError::InvalidStartPosition(_)) + )); + } + + #[test] + fn maze_boundaries() { + let radius = 3; + let maze = MazeBuilder::new().with_radius(radius).build().unwrap(); + + // Test that tiles exist within the radius + for q in -(radius as i32)..=(radius as i32) { + for r in -(radius as i32)..=(radius as i32) { + let pos = Hex::new(q, r); + if q.abs() + r.abs() <= radius as i32 { + assert!( + maze.get_tile(&pos).is_some(), + "Expected tile at {:?} to exist", + pos + ); + } + } + } + } + + #[test] + fn different_radii() { + for radius in 1..=5 { + let maze = MazeBuilder::new().with_radius(radius).build().unwrap(); + + assert_eq!( + maze.len(), + calculate_hex_tiles(radius), + "Incorrect number of tiles for radius {}", + radius + ); + } + } + + #[test] + fn wall_consistency() { + let radius = 3; + let maze = MazeBuilder::new().with_radius(radius).build().unwrap(); + + // Check that if tile A has no wall to tile B, + // then tile B has no wall to tile A + for &pos in maze.keys() { + for &dir in &EdgeDirection::ALL_DIRECTIONS { + let neighbor = pos + dir; + if let (Some(walls), Some(neighbor_walls)) = + (maze.get_walls(&pos), maze.get_walls(&neighbor)) + { + assert_eq!( + walls.contains(dir), + neighbor_walls.contains(dir.const_neg()), + "Wall inconsistency between {:?} and {:?}", + pos, + neighbor + ); + } } } } diff --git a/src/generator.rs b/src/generator.rs index efab22c..6b7b1a8 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1,5 +1,49 @@ +use std::collections::HashSet; + +use hexx::{EdgeDirection, Hex}; +use rand::{seq::SliceRandom, thread_rng, Rng, RngCore, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +use crate::HexMaze; + #[derive(Debug, Clone, Copy, Default)] pub enum GeneratorType { #[default] RecursiveBacktracking, } + +pub(crate) fn generate_backtracking(maze: &mut HexMaze, start_pos: Option, seed: Option) { + if maze.is_empty() { + return; + } + + let start = start_pos.unwrap_or(Hex::ZERO); + + let mut visited = HashSet::new(); + + let mut rng: Box = match seed { + Some(seed) => Box::new(ChaCha8Rng::seed_from_u64(seed)), + None => Box::new(thread_rng()), + }; + recursive_backtrack(maze, start, &mut visited, &mut rng); +} + +fn recursive_backtrack( + maze: &mut HexMaze, + current: Hex, + visited: &mut HashSet, + rng: &mut R, +) { + visited.insert(current); + let mut directions = EdgeDirection::ALL_DIRECTIONS; + directions.shuffle(rng); + + for direction in directions { + let neighbor = current + direction; + if maze.get_tile(&neighbor).is_some() && !visited.contains(&neighbor) { + maze.remove_tile_wall(¤t, direction); + maze.remove_tile_wall(&neighbor, direction.const_neg()); + recursive_backtrack(maze, neighbor, visited, rng); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 43d23b3..0e8e955 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,13 +4,13 @@ mod maze; mod tile; mod walls; -pub use builder::MazeBuilder; +pub use builder::{MazeBuilder, MazeBuilderError}; pub use generator::GeneratorType; pub use maze::HexMaze; pub use tile::HexTile; pub use walls::Walls; pub mod prelude { - pub use super::{GeneratorType, HexMaze, HexTile, MazeBuilder, Walls}; + pub use super::{GeneratorType, HexMaze, HexTile, MazeBuilder, MazeBuilderError, Walls}; pub use hexx::{EdgeDirection, Hex, HexLayout}; }