//! The v1 **dungeon** recipe (spec §4.8): a validated five-pass pipeline. //! //! [`dungeon`] is the project's flagship recipe. It takes a [`DungeonConfig`], //! validates it (spec §8), and — on success — returns a //! [`Pipeline`](crate::pass::Pipeline) that assembles the full v1 chain in //! order: //! //! ```text //! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer //! ``` //! //! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder //! Room region per leaf). //! 2. [`RoomCarver`] carves an actual room inside each leaf. //! 3. [`MstConnect`] plans which rooms link up (a minimum spanning tree, plus //! optional loop edges). //! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge. //! 5. [`DoorPlacer`] marks a fraction of the room↔corridor pierce points as //! doors (purely cosmetic — connectivity is already established by the floor). //! //! ## Why validation matters (spec §8) //! //! [`RoomCarver`] *skips* any leaf too small to hold a `min_size` room after its //! `margin` is reserved (see [`crate::passes::room`]). A skipped leaf stays a //! placeholder Room with empty `cells`, and [`MstConnect`] only connects *real* //! rooms (non-empty `cells`) — so a skipped leaf would simply not be carved, not //! a correctness bug, but it muddies the "every leaf is a room" contract the //! integration tests lean on. The recipe therefore refuses, up front, any config //! where the smallest possible leaf cannot hold a room: see //! [`DungeonConfig::validate`] for the exact inequality. This guarantees every //! leaf becomes a real room, so the MST spans them all and the whole dungeon is //! connected. use std::error::Error; use std::fmt; use crate::pass::Pipeline; use crate::passes::{ BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect, RoomCarver, RoomConfig, }; /// Configuration for the [`dungeon`] recipe. /// /// Composes the canvas dimensions with each pass's own config. The [`Default`] /// produces a recognizable multi-room dungeon on a 64×40 canvas, with per-pass /// defaults chosen to be mutually consistent so **no** BSP leaf is ever skipped /// by [`RoomCarver`] (see [`validate`](DungeonConfig::validate)). #[derive(Clone, Copy, Debug, PartialEq)] pub struct DungeonConfig { /// Canvas width in cells. pub width: u32, /// Canvas height in cells. pub height: u32, /// How the canvas is partitioned into leaf rectangles. pub bsp: BspConfig, /// How a room is carved inside each leaf. pub rooms: RoomConfig, /// How rooms are linked into a connectivity graph. pub connect: ConnectConfig, /// How room↔corridor pierce points are marked as doors. pub doors: DoorConfig, } impl Default for DungeonConfig { /// A recognizable multi-room dungeon: a 64×40 canvas with each pass at its /// own sensible default. /// /// The per-pass defaults are mutually consistent: with `bsp.min_leaf = 6`, /// `rooms.margin = 1`, and `rooms.min_size = 4`, the smallest possible leaf /// (6 cells on a side) deflates to a 4×4 interior — exactly `min_size` — so /// every leaf carves a room and none is skipped (the inequality /// `min_leaf - 2*margin >= min_size` holds: `6 - 2 >= 4`). fn default() -> Self { DungeonConfig { width: 64, height: 40, bsp: BspConfig::default(), rooms: RoomConfig::default(), connect: ConnectConfig::default(), doors: DoorConfig::default(), } } } /// Why a [`DungeonConfig`] was rejected at recipe construction (spec §8). /// /// Returned by [`dungeon`] instead of panicking, so a caller (or a future /// interactive editor) can surface the problem and let the user fix the config. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ConfigError { /// The canvas has a zero width or height — nothing could be generated. ZeroDimension, /// `rooms.min_size` is less than 1: a room needs at least one cell. RoomTooSmall, /// `rooms.max_size` is smaller than `rooms.min_size`: the size range is /// empty/inverted. MaxSmallerThanMin, /// `rooms.margin` is negative: a margin reserves cells, it cannot be negative. NegativeMargin, /// The smallest possible BSP leaf cannot hold a `min_size` room after its /// margin is reserved — i.e. `bsp.min_leaf - 2*rooms.margin < rooms.min_size`. /// Some leaf would be skipped, breaking the "every leaf is a room" contract. RoomLargerThanLeaf, } impl fmt::Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ConfigError::ZeroDimension => { write!(f, "dungeon width and height must both be greater than zero") } ConfigError::RoomTooSmall => { write!(f, "rooms.min_size must be at least 1") } ConfigError::MaxSmallerThanMin => { write!(f, "rooms.max_size must be >= rooms.min_size") } ConfigError::NegativeMargin => { write!(f, "rooms.margin must be >= 0") } ConfigError::RoomLargerThanLeaf => write!( f, "the smallest BSP leaf cannot hold a room: require \ bsp.min_leaf - 2*rooms.margin >= rooms.min_size" ), } } } impl Error for ConfigError {} impl DungeonConfig { /// Validates this config, returning the first [`ConfigError`] found or `Ok`. /// /// The load-bearing check is [`RoomLargerThanLeaf`](ConfigError::RoomLargerThanLeaf). /// [`BspPartition`] only cuts a leaf when a dimension is at least /// `2 * min_leaf`, keeping each resulting half at least `min_leaf` — so /// `bsp.min_leaf` is the *smallest* extent any leaf can have on either axis. /// [`RoomCarver::pick_room`] then skips a leaf when its margin-deflated /// interior (`leaf.inflate(-margin)`, i.e. extent `leaf - 2*margin`) is /// smaller than `min_size`. Substituting the worst case `leaf = min_leaf`, /// no leaf is skipped iff /// /// ```text /// min_leaf - 2*margin >= min_size /// ``` /// /// Enforcing that here guarantees every leaf carves a real room, which is /// what makes the assembled dungeon fully connected (the MST spans every /// leaf-room). pub fn validate(&self) -> Result<(), ConfigError> { if self.width == 0 || self.height == 0 { return Err(ConfigError::ZeroDimension); } if self.rooms.min_size < 1 { return Err(ConfigError::RoomTooSmall); } if self.rooms.max_size < self.rooms.min_size { return Err(ConfigError::MaxSmallerThanMin); } if self.rooms.margin < 0 { return Err(ConfigError::NegativeMargin); } // The smallest leaf (extent `min_leaf`) must still fit a `min_size` room // after `margin` is reserved on every side. if self.bsp.min_leaf - 2 * self.rooms.margin < self.rooms.min_size { return Err(ConfigError::RoomLargerThanLeaf); } Ok(()) } } /// Builds the v1 dungeon [`Pipeline`] from `cfg`, or a [`ConfigError`] if the /// config is invalid (spec §4.8, §8). /// /// On success the returned pipeline is sized to `cfg.width`×`cfg.height` and /// holds the five passes in order: [`BspPartition`], [`RoomCarver`], /// [`MstConnect`], [`CorridorCarver`], [`DoorPlacer`]. It carries no seed — /// call [`Pipeline::run`] (or /// [`run_with_snapshots`](Pipeline::run_with_snapshots)) with a seed to generate. /// /// Validation runs first and never panics on a bad config. pub fn dungeon(cfg: DungeonConfig) -> Result { cfg.validate()?; let pipeline = Pipeline::new(cfg.width, cfg.height) .then(BspPartition::new(cfg.bsp)) .then(RoomCarver::new(cfg.rooms)) .then(MstConnect::new(cfg.connect)) .then(CorridorCarver::new()) .then(DoorPlacer::new(cfg.doors)); Ok(pipeline) } #[cfg(test)] mod tests { use super::*; use crate::geometry::Point; use crate::map::{Map, Tile}; use crate::region::RegionKind; use std::collections::{BTreeSet, VecDeque}; /// The five pass names in pipeline order, used to check snapshot labels. const PASS_NAMES: [&str; 5] = [ "bsp_partition", "room_carver", "mst_connect", "corridor_carver", "door_placer", ]; /// Is `p` a walkable cell (Floor or Door are both walkable; Wall is not)? fn is_walkable(map: &Map, p: Point) -> bool { matches!(map.tiles.get(p), Some(&Tile::Floor) | Some(&Tile::Door)) } /// The real Room regions of a map: kind == Room AND a non-empty cell set. /// (Empty-`cells` Rooms are leaves RoomCarver skipped; our validated config /// never produces any, but the helper is honest about the definition.) fn real_rooms(map: &Map) -> Vec<&crate::region::Region> { map.regions .iter() .filter(|r| r.kind == RegionKind::Room && !r.cells.is_empty()) .collect() } /// Flood-fills the walkable (Floor|Door) component containing `start`, /// returning the set of reached cells as `(x, y)` pairs. fn walkable_component(map: &Map, start: Point) -> BTreeSet<(i32, i32)> { let mut seen: BTreeSet<(i32, i32)> = BTreeSet::new(); if !is_walkable(map, start) { return seen; } let mut queue: VecDeque = VecDeque::new(); seen.insert((start.x, start.y)); queue.push_back(start); while let Some(p) = queue.pop_front() { for n in map.tiles.neighbors4(p) { if is_walkable(map, n) && seen.insert((n.x, n.y)) { queue.push_back(n); } } } seen } // --- Test 1: determinism ------------------------------------------------ /// Same seed → equal `Map`s and identical ASCII renders. #[test] fn same_seed_produces_identical_dungeons() { let cfg = DungeonConfig::default(); let a = dungeon(cfg).unwrap().run(42); let b = dungeon(cfg).unwrap().run(42); assert_eq!(a, b, "same seed must reproduce a byte-identical Map"); assert_eq!( a.to_ascii(), b.to_ascii(), "same seed must reproduce an identical ASCII render" ); } // --- Test 2: run == run_with_snapshots ---------------------------------- /// `run` and `run_with_snapshots` agree on the map for a seed; there are /// exactly five snapshots and their labels are the five pass names in order. #[test] fn run_matches_run_with_snapshots_and_labels() { let cfg = DungeonConfig::default(); let plain = dungeon(cfg).unwrap().run(7); let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7); assert_eq!(plain, snapped, "snapshotting must not change the map"); assert_eq!(snapshots.len(), 5, "one snapshot per pass (five passes)"); let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect(); assert_eq!( labels, PASS_NAMES.to_vec(), "snapshot labels must be the five pass names in pipeline order" ); } // --- Test 3: connectivity (batch of seeds) ------------------------------ /// Over a batch of seeds, every real Room region has at least one interior /// cell reachable (walking Floor|Door) from the first real room — no /// orphaned rooms. This is the whole-map connectivity contract. #[test] fn every_room_is_reachable_across_many_seeds() { let cfg = DungeonConfig::default(); for seed in 1..=64u64 { let map = dungeon(cfg).unwrap().run(seed); let rooms = real_rooms(&map); assert!( rooms.len() >= 2, "seed {seed}: expected a multi-room dungeon, got {} rooms", rooms.len() ); // Flood-fill from the first real room's first interior cell. let start = rooms[0].cells[0]; let component = walkable_component(&map, start); for room in &rooms { // A room is reachable if ANY of its interior cells is in the // walkable component grown from the start room. let reachable = room .cells .iter() .any(|c| component.contains(&(c.x, c.y))); assert!( reachable, "seed {seed}: room {:?} (bounds {:?}) is orphaned — \ no interior cell reachable from the start room", room.id, room.bounds ); } } } // --- Test 4: bounds ----------------------------------------------------- /// No non-`Wall` tile lies outside the map, and every Room region's cells lie /// within `[0, width) × [0, height)`. #[test] fn all_floor_and_rooms_are_in_bounds() { let cfg = DungeonConfig::default(); let (w, h) = (cfg.width as i32, cfg.height as i32); for seed in [1u64, 2, 3, 99, 12345] { let map = dungeon(cfg).unwrap().run(seed); // The grid is exactly the configured size... assert_eq!((map.width, map.height), (cfg.width, cfg.height)); assert_eq!((map.tiles.width(), map.tiles.height()), (cfg.width, cfg.height)); // ...and `iter()` only ever yields in-grid cells, so any non-Wall // tile is by construction in bounds. We assert it explicitly to make // the bounds contract visible. for (p, &t) in map.tiles.iter() { if t != Tile::Wall { assert!( p.x >= 0 && p.x < w && p.y >= 0 && p.y < h, "seed {seed}: non-Wall tile at {p:?} is out of bounds" ); } } // Every Room region's cells are within the map. for room in real_rooms(&map) { for &c in &room.cells { assert!( c.x >= 0 && c.x < w && c.y >= 0 && c.y < h, "seed {seed}: room {:?} cell {c:?} out of bounds", room.id ); } } } } // --- Test 5: config validation ------------------------------------------ /// Zero width and zero height each return `Err(ZeroDimension)` — never a /// panic. #[test] fn zero_dimensions_are_rejected() { let zero_w = DungeonConfig { width: 0, ..DungeonConfig::default() }; let zero_h = DungeonConfig { height: 0, ..DungeonConfig::default() }; assert_eq!(dungeon(zero_w).err(), Some(ConfigError::ZeroDimension)); assert_eq!(dungeon(zero_h).err(), Some(ConfigError::ZeroDimension)); } /// A `min_size` too large for `min_leaf` returns `Err(RoomLargerThanLeaf)`. #[test] fn room_larger_than_leaf_is_rejected() { // min_leaf 6, margin 1 => smallest interior is 6 - 2 = 4. A min_size of 5 // cannot fit, so this must be rejected. let cfg = DungeonConfig { bsp: BspConfig { min_leaf: 6, max_depth: 4, }, rooms: RoomConfig { min_size: 5, max_size: 10, margin: 1, }, ..DungeonConfig::default() }; assert_eq!(dungeon(cfg).err(), Some(ConfigError::RoomLargerThanLeaf)); } /// The exact boundary of the inequality is accepted: `min_leaf - 2*margin == /// min_size` is fine (the default config sits right on this edge). #[test] fn boundary_config_is_accepted() { // 6 - 2*1 == 4 == min_size: must be Ok. assert!(DungeonConfig::default().validate().is_ok()); assert!(dungeon(DungeonConfig::default()).is_ok()); } /// Other malformed configs are rejected, never panic: an inverted size range, /// a sub-1 min_size, and a negative margin. #[test] fn other_malformed_configs_are_rejected() { let inverted = DungeonConfig { rooms: RoomConfig { min_size: 8, max_size: 4, margin: 0, }, bsp: BspConfig { min_leaf: 20, max_depth: 3, }, ..DungeonConfig::default() }; assert_eq!(dungeon(inverted).err(), Some(ConfigError::MaxSmallerThanMin)); let tiny = DungeonConfig { rooms: RoomConfig { min_size: 0, max_size: 4, margin: 0, }, ..DungeonConfig::default() }; assert_eq!(dungeon(tiny).err(), Some(ConfigError::RoomTooSmall)); let neg_margin = DungeonConfig { rooms: RoomConfig { min_size: 2, max_size: 4, margin: -1, }, ..DungeonConfig::default() }; assert_eq!(dungeon(neg_margin).err(), Some(ConfigError::NegativeMargin)); } // --- Test 6: doors ------------------------------------------------------ /// Counts the `Door` tiles in a map. fn door_count(map: &Map) -> usize { map.tiles.iter().filter(|&(_, &t)| t == Tile::Door).count() } /// At the default door_chance (0.5) a multi-room dungeon has at least one /// `Door` tile and at least one connected edge with `at == Some(_)`. We scan /// a few seeds so the assertion is a robust regression guard, not a coin /// flip on one seed. #[test] fn default_dungeon_has_doors_and_attributed_edges() { let cfg = DungeonConfig::default(); let mut saw_door_tile = false; let mut saw_attributed_edge = false; for seed in 1..=16u64 { let map = dungeon(cfg).unwrap().run(seed); if door_count(&map) > 0 { saw_door_tile = true; } if map.graph.edges().iter().any(|e| e.at.is_some()) { saw_attributed_edge = true; } } assert!( saw_door_tile, "a default-door-chance dungeon should place at least one Door tile" ); assert!( saw_attributed_edge, "at least one connected edge should have at == Some(_)" ); } /// door_chance = 0.0 yields ZERO doors yet the dungeon is STILL fully /// connected — proving doors are cosmetic and connectivity is via floor. #[test] fn zero_door_chance_has_no_doors_but_stays_connected() { let cfg = DungeonConfig { doors: DoorConfig { door_chance: 0.0 }, ..DungeonConfig::default() }; for seed in 1..=16u64 { let map = dungeon(cfg).unwrap().run(seed); assert_eq!( door_count(&map), 0, "seed {seed}: door_chance=0.0 must place no doors" ); // Still fully connected over Floor alone (no doors exist). let rooms = real_rooms(&map); let start = rooms[0].cells[0]; let component = walkable_component(&map, start); for room in &rooms { assert!( room.cells.iter().any(|c| component.contains(&(c.x, c.y))), "seed {seed}: room {:?} orphaned with no doors — \ connectivity must be via floor", room.id ); } } } /// door_chance = 1.0 yields strictly more doors than door_chance = 0.5 on the /// same seed (the mix knob actually works). We sum across seeds to avoid a /// single seed where the two happen to tie. #[test] fn higher_door_chance_yields_more_doors() { let half = DungeonConfig { doors: DoorConfig { door_chance: 0.5 }, ..DungeonConfig::default() }; let full = DungeonConfig { doors: DoorConfig { door_chance: 1.0 }, ..DungeonConfig::default() }; let mut total_half = 0usize; let mut total_full = 0usize; for seed in 1..=16u64 { total_half += door_count(&dungeon(half).unwrap().run(seed)); total_full += door_count(&dungeon(full).unwrap().run(seed)); } assert!( total_full > total_half, "door_chance=1.0 ({total_full} doors) must exceed door_chance=0.5 \ ({total_half} doors) across seeds" ); // And 1.0 should place a door at every threshold, so there's at least one. assert!(total_full > 0, "door_chance=1.0 must place doors"); } // --- Test 7: eyeball render --------------------------------------------- /// Prints one dungeon for eyeball confirmation under `--nocapture`. Not an /// assertion (beyond non-empty output); it documents what the recipe makes. #[test] fn print_one_dungeon_for_eyeballing() { let cfg = DungeonConfig::default(); let map = dungeon(cfg).unwrap().run(2026); let ascii = map.to_ascii(); println!( "\n--- dungeon seed=2026 {}x{} ---\n{}\n--- end dungeon ---", cfg.width, cfg.height, ascii ); assert!(!ascii.is_empty()); } }