//! The [`BspPartition`] partitioner pass (spec §4.7). //! //! Binary space partitioning recursively cuts the whole-map rectangle into //! smaller, non-overlapping leaf rectangles. Each cut chooses a split axis and a //! split position at random (from the pass's dedicated [`Rng`] sub-stream), and //! recursion stops at a leaf once it is too small to split without violating the //! minimum leaf size, or once the configured maximum recursion depth is reached. //! //! Per the inter-pass data contract (see [`crate::passes`]), this pass is the //! first in the v1 dungeon recipe: it reads an empty grid and **writes** one //! placeholder [`Region`](crate::region::Region) of kind //! [`Room`](crate::region::RegionKind::Room) per leaf — `bounds` set to the leaf //! rect and `cells` empty. It carves **no** tiles; the grid stays all //! [`Wall`](crate::map::Tile::Wall). A later `RoomCarver` pass consumes these //! placeholders, shrinks each to an actual room, and carves floor. use serde::{Deserialize, Serialize}; use crate::geometry::Rect; use crate::pass::{GenContext, Pass}; use crate::region::RegionKind; use crate::rng::Rng; /// Configuration for a [`BspPartition`] pass. /// /// `min_leaf` is the smallest allowed extent (in cells) for any dimension of a /// leaf rectangle: a cut is only made when both resulting halves keep every /// dimension at or above this size. `max_depth` caps how many times the /// recursion may split; `max_depth: 0` produces a single leaf — the whole map. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BspConfig { /// The minimum allowed extent of any leaf dimension, in cells. pub min_leaf: i32, /// The maximum recursion depth. `0` yields a single whole-map leaf. pub max_depth: u32, } impl Default for BspConfig { /// A sensible default for the v1 dungeon: leaves no smaller than 6 cells on /// a side, recursing up to 4 levels deep (up to 16 leaves). fn default() -> Self { BspConfig { min_leaf: 6, max_depth: 4, } } } /// A binary-space-partition partitioner pass (spec §4.7). /// /// Splits the map into leaf rectangles and records one placeholder /// [`Room`](crate::region::RegionKind::Room) region per leaf. Construct one with /// [`BspPartition::new`]; it implements [`Pass`] with the stable name /// `"bsp_partition"`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct BspPartition { /// The split limits this pass partitions under. cfg: BspConfig, } impl BspPartition { /// Creates a partitioner with the given configuration. pub fn new(cfg: BspConfig) -> Self { BspPartition { cfg } } /// Recursively partitions `rect`, appending each final leaf to `leaves`. /// /// `depth` counts down from the configured maximum: recursion stops when it /// reaches `0` or when `rect` is too small to split (neither axis has room /// for two halves each at least `min_leaf` wide). Randomness — the axis /// choice and the split offset — is drawn from `rng` in a fixed order, so the /// resulting leaf set is fully determined by the seed. fn split(&self, rect: Rect, depth: u32, rng: &mut Rng, leaves: &mut Vec) { let min = self.cfg.min_leaf; // A leaf must be at least `2 * min` along an axis to be cut into two // halves that each keep `min`. With `min` non-positive every rect is // "splittable", so guard against it to keep the recursion well-founded. let can_split_w = min > 0 && rect.w >= 2 * min; let can_split_h = min > 0 && rect.h >= 2 * min; if depth == 0 || (!can_split_w && !can_split_h) { leaves.push(rect); return; } // Choose the split axis. `true` = vertical cut (split the width into a // left/right pair); `false` = horizontal cut (top/bottom pair). When only // one axis is splittable we must take it; when both are, pick at random. let split_vertical = match (can_split_w, can_split_h) { (true, false) => true, (false, true) => false, _ => rng.chance(0.5), }; let (first, second) = if split_vertical { // Offset is the left half's width: in `[min, w - min]` inclusive, so // both halves are at least `min` wide. `range` is half-open, hence // the `+ 1` on the upper bound. let offset = rng.range(min, rect.w - min + 1); ( Rect::new(rect.x, rect.y, offset, rect.h), Rect::new(rect.x + offset, rect.y, rect.w - offset, rect.h), ) } else { let offset = rng.range(min, rect.h - min + 1); ( Rect::new(rect.x, rect.y, rect.w, offset), Rect::new(rect.x, rect.y + offset, rect.w, rect.h - offset), ) }; self.split(first, depth - 1, rng, leaves); self.split(second, depth - 1, rng, leaves); } } impl Pass for BspPartition { fn name(&self) -> &str { "bsp_partition" } fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) { // The whole canvas is the root rectangle to partition. let map_rect = Rect::new(0, 0, ctx.tiles.width() as i32, ctx.tiles.height() as i32); let mut leaves = Vec::new(); self.split(map_rect, self.cfg.max_depth, rng, &mut leaves); // One placeholder Room per leaf: bounds = leaf rect, no cells, no tiles // carved. `RoomCarver` refines these later. for leaf in leaves { ctx.add_region(RegionKind::Room, leaf, Vec::new()); } } } #[cfg(test)] mod tests { use super::*; use crate::blackboard::Blackboard; use crate::geometry::Point; use crate::grid::Grid; use crate::map::Tile; use crate::region::ConnGraph; /// Builds the precondition this pass requires: a fresh `GenContext` whose /// grid is all `Wall` and whose regions/graph/blackboard are empty — exactly /// the state `Pipeline::run` would hand the first pass. Constructed by hand /// so the test depends on no other pass. fn empty_ctx(width: u32, height: u32) -> GenContext { GenContext { tiles: Grid::new(width, height, Tile::Wall), regions: Vec::new(), graph: ConnGraph::new(), blackboard: Blackboard::new(), } } /// Runs `BspPartition` with the given config/seed on a fresh `width`×`height` /// context and returns the resulting context for inspection. fn run(width: u32, height: u32, cfg: BspConfig, seed: u64) -> GenContext { let mut ctx = empty_ctx(width, height); // Mirror the pipeline's keying so we exercise the real sub-stream path. let mut rng = Rng::from_seed(seed).fork("bsp_partition#0"); BspPartition::new(cfg).apply(&mut ctx, &mut rng); ctx } /// The pass identifies itself with the exact contract name. #[test] fn name_is_bsp_partition() { assert_eq!(BspPartition::new(BspConfig::default()).name(), "bsp_partition"); } /// After running on an empty context, `regions` is non-empty and every /// region is a `Room` placeholder (kind = Room, empty `cells`). #[test] fn produces_nonempty_room_placeholders() { let ctx = run(64, 48, BspConfig::default(), 0xABCD); assert!(!ctx.regions.is_empty(), "partition must yield at least one leaf"); assert!( ctx.regions.iter().all(|r| r.kind == RegionKind::Room), "every leaf region must be a Room" ); assert!( ctx.regions.iter().all(|r| r.cells.is_empty()), "placeholders carry no cells" ); } /// The pass carves no tiles: the grid stays entirely `Wall`. #[test] fn carves_no_tiles() { let mut ctx = empty_ctx(64, 48); let mut rng = Rng::from_seed(0xABCD).fork("bsp_partition#0"); BspPartition::new(BspConfig::default()).apply(&mut ctx, &mut rng); assert!( ctx.tiles.iter().all(|(_, &t)| t == Tile::Wall), "partitioner must not carve any tile" ); } /// Every leaf lies fully within the map and respects `min_leaf` on both /// dimensions. #[test] fn leaves_in_bounds_and_respect_min_leaf() { let cfg = BspConfig { min_leaf: 5, max_depth: 5, }; let (w, h) = (80, 50); let map = Rect::new(0, 0, w as i32, h as i32); let ctx = run(w, h, cfg, 0x1234_5678); for r in &ctx.regions { let b = r.bounds; // Within bounds. assert!( b.x >= 0 && b.y >= 0 && b.right() <= map.right() && b.bottom() <= map.bottom(), "leaf {b:?} escapes the map {map:?}" ); // Respects the minimum on both axes. assert!( b.w >= cfg.min_leaf && b.h >= cfg.min_leaf, "leaf {b:?} violates min_leaf {}", cfg.min_leaf ); } } /// Leaves do not overlap and their areas tile the whole map exactly (no gap, /// no overlap): the sum of leaf areas equals the map area, and no pair of /// leaves intersects. #[test] fn leaves_do_not_overlap_and_cover_the_map() { let cfg = BspConfig { min_leaf: 4, max_depth: 6, }; let (w, h) = (70, 56); let ctx = run(w, h, cfg, 0xDEAD_BEEF); let leaves: Vec = ctx.regions.iter().map(|r| r.bounds).collect(); // No two distinct leaves intersect. for (i, a) in leaves.iter().enumerate() { for b in &leaves[i + 1..] { assert!(!a.intersects(b), "leaves overlap: {a:?} and {b:?}"); } } // Areas sum to the whole map: a partition leaves no gaps. let total: i64 = leaves.iter().map(|r| r.w as i64 * r.h as i64).sum(); assert_eq!( total, w as i64 * h as i64, "leaf areas must tile the map exactly" ); // Stronger check: every map cell is covered by exactly one leaf. for y in 0..h as i32 { for x in 0..w as i32 { let p = Point::new(x, y); let covering = leaves.iter().filter(|r| r.contains(p)).count(); assert_eq!(covering, 1, "cell {p:?} covered by {covering} leaves"); } } } /// Determinism: the same seed (same sub-stream) yields an identical leaf set /// (compare region bounds in order). #[test] fn same_seed_yields_identical_leaves() { let cfg = BspConfig::default(); let a = run(96, 64, cfg, 0x5EED); let b = run(96, 64, cfg, 0x5EED); let bounds_a: Vec = a.regions.iter().map(|r| r.bounds).collect(); let bounds_b: Vec = b.regions.iter().map(|r| r.bounds).collect(); assert_eq!(bounds_a, bounds_b, "same seed must reproduce the same leaves"); } /// Different seeds (overwhelmingly) yield a different partition. #[test] fn distinct_seeds_diverge() { let cfg = BspConfig::default(); let a = run(96, 64, cfg, 1); let b = run(96, 64, cfg, 2); let bounds_a: Vec = a.regions.iter().map(|r| r.bounds).collect(); let bounds_b: Vec = b.regions.iter().map(|r| r.bounds).collect(); assert_ne!(bounds_a, bounds_b); } /// `max_depth: 0` yields a single leaf: the whole map. #[test] fn max_depth_zero_is_single_whole_map_leaf() { let cfg = BspConfig { min_leaf: 4, max_depth: 0, }; let (w, h) = (40, 30); let ctx = run(w, h, cfg, 999); assert_eq!(ctx.regions.len(), 1, "depth 0 must produce exactly one leaf"); assert_eq!(ctx.regions[0].kind, RegionKind::Room); assert_eq!( ctx.regions[0].bounds, Rect::new(0, 0, w as i32, h as i32), "the single leaf must be the whole map" ); } /// A map too small to split (every extent below `2 * min_leaf`) yields the /// whole map as one leaf, even with depth budget to spare. Bounds-safety: /// no panic, no underflow. #[test] fn unsplittable_map_yields_single_leaf() { let cfg = BspConfig { min_leaf: 10, max_depth: 8, }; // 12×12 < 2*10 on both axes, so no cut is possible. let ctx = run(12, 12, cfg, 42); assert_eq!(ctx.regions.len(), 1); assert_eq!(ctx.regions[0].bounds, Rect::new(0, 0, 12, 12)); } /// Bounds-safety on degenerate inputs: a zero-sized map and a degenerate /// `min_leaf` must not panic and must still leave the grid all `Wall`. #[test] fn degenerate_inputs_do_not_panic() { // Zero-area map. let ctx = run(0, 0, BspConfig::default(), 7); assert_eq!(ctx.regions.len(), 1); assert_eq!(ctx.regions[0].bounds, Rect::new(0, 0, 0, 0)); // min_leaf == 0: must not enable infinite recursion; treated as // unsplittable so a single leaf results. let ctx = run( 32, 32, BspConfig { min_leaf: 0, max_depth: 4, }, 7, ); assert_eq!(ctx.regions.len(), 1); assert_eq!(ctx.regions[0].bounds, Rect::new(0, 0, 32, 32)); assert!(ctx.tiles.iter().all(|(_, &t)| t == Tile::Wall)); } }