From 4e54d3b7ba98d7f632a3def0ed3bd1fb539947e0 Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Thu, 28 May 2026 22:24:53 -0600 Subject: [PATCH] feat(core): dungeon recipe + end-to-end integration tests Co-Authored-By: Claude Opus 4.8 (1M context) --- reikhelm-core/src/lib.rs | 1 + reikhelm-core/src/recipes/dungeon.rs | 563 +++++++++++++++++++++++++++ reikhelm-core/src/recipes/mod.rs | 14 + 3 files changed, 578 insertions(+) create mode 100644 reikhelm-core/src/recipes/dungeon.rs create mode 100644 reikhelm-core/src/recipes/mod.rs diff --git a/reikhelm-core/src/lib.rs b/reikhelm-core/src/lib.rs index 7855659..8815f5b 100644 --- a/reikhelm-core/src/lib.rs +++ b/reikhelm-core/src/lib.rs @@ -10,5 +10,6 @@ pub mod grid; pub mod map; pub mod pass; pub mod passes; +pub mod recipes; pub mod region; pub mod rng; diff --git a/reikhelm-core/src/recipes/dungeon.rs b/reikhelm-core/src/recipes/dungeon.rs new file mode 100644 index 0000000..dbdb6f3 --- /dev/null +++ b/reikhelm-core/src/recipes/dungeon.rs @@ -0,0 +1,563 @@ +//! 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()); + } +} diff --git a/reikhelm-core/src/recipes/mod.rs b/reikhelm-core/src/recipes/mod.rs new file mode 100644 index 0000000..f43370a --- /dev/null +++ b/reikhelm-core/src/recipes/mod.rs @@ -0,0 +1,14 @@ +//! Generation **recipes**: named, validated assemblies of passes (spec §4.8). +//! +//! Where [`crate::passes`] is the *vocabulary* of individual generation steps, a +//! recipe is a *sentence*: it picks a concrete sequence of passes, sizes them to +//! a canvas, and hands back a ready-to-run [`crate::pass::Pipeline`]. A recipe +//! validates its configuration up front (spec §8) so a caller learns about an +//! impossible request as a [`dungeon::ConfigError`] rather than as a degenerate +//! map or a panic deep inside a pass. +//! +//! v1 ships exactly one recipe — [`dungeon`](dungeon::dungeon) — which assembles +//! the full five-pass dungeon chain (BSP → rooms → MST-connect → corridors → +//! doors). Future map types (towns, caves) would add sibling modules here. + +pub mod dungeon;