From d2d5fa1a8bf811961e97e911d83e52cfe9aac0bd Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Thu, 28 May 2026 21:30:28 -0600 Subject: [PATCH] feat(core): RoomCarver pass Co-Authored-By: Claude Opus 4.8 (1M context) --- reikhelm-core/src/passes/mod.rs | 3 + reikhelm-core/src/passes/room.rs | 427 +++++++++++++++++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 reikhelm-core/src/passes/room.rs diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index a2abb4f..b3b0414 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -25,3 +25,6 @@ pub mod bsp; pub use bsp::{BspConfig, BspPartition}; + +pub mod room; +pub use room::{RoomCarver, RoomConfig}; diff --git a/reikhelm-core/src/passes/room.rs b/reikhelm-core/src/passes/room.rs new file mode 100644 index 0000000..aba87cb --- /dev/null +++ b/reikhelm-core/src/passes/room.rs @@ -0,0 +1,427 @@ +//! The [`RoomCarver`] shaper pass (spec §4.7). +//! +//! A *shaper* carves open space within the regions a partitioner produced. This +//! pass consumes the placeholder [`Room`](crate::region::RegionKind::Room) +//! regions that `BspPartition` left behind — one per BSP leaf, each with its +//! `bounds` set to the leaf rect and empty `cells` — and turns each into an +//! actual room. +//! +//! Per the inter-pass data contract (see [`crate::passes`]), for every existing +//! `Room` region it: +//! +//! 1. Picks a room rectangle that fits inside the leaf, leaving a [`margin`] of +//! cells from the leaf edge so corridors and walls have somewhere to live. +//! The room's width and height are random within +//! [`min_size`](RoomConfig::min_size)..=[`max_size`](RoomConfig::max_size), +//! capped to what the margin-shrunk leaf can hold. +//! 2. Carves [`Floor`](crate::map::Tile::Floor) over that rectangle in +//! `ctx.tiles`. +//! 3. Shrinks the region's `bounds` to the actual room rect and fills `cells` +//! with the carved floor points (row-major order). +//! +//! A leaf too small to hold a `min_size` room — even before the margin — is +//! **skipped gracefully**: its region is left in place with empty `cells` +//! (never removed, which would break the `RegionId`-as-index invariant of +//! [`RegionId`](crate::region::RegionId)). A `Room` region with empty `cells` +//! denotes "not actually a room"; downstream room consumers (`MstConnect`, +//! `CorridorCarver`) treat only `Room` regions with non-empty `cells` as real +//! rooms, and `DoorPlacer` relies on `cells` being exactly the room interior +//! floor so it can tell room-floor from corridor-floor. In the assembled +//! dungeon recipe this skip never fires (Task 12 validates that every leaf fits +//! a room), but the pass must handle it without panicking (spec §8). +//! +//! This pass reads/writes `ctx.regions` and `ctx.tiles` only and does **not** +//! depend on `BspPartition`'s code — only on the documented "placeholder Room +//! regions" precondition, which its tests construct directly. + +use crate::geometry::Rect; +use crate::map::Tile; +use crate::pass::{GenContext, Pass}; +use crate::region::RegionKind; +use crate::rng::Rng; + +/// Configuration for a [`RoomCarver`] pass. +/// +/// A room's width and height are each drawn from the inclusive range +/// `min_size..=max_size`, then capped to whatever the leaf can hold after a +/// `margin` of cells is reserved on every side. The margin keeps room floors off +/// the leaf boundary so the surrounding wall — and the corridors that pierce it +/// — have room to exist. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RoomConfig { + /// The smallest allowed room extent (in cells) on either axis. A leaf that + /// cannot hold a `min_size`-square room (after the margin) is skipped. + pub min_size: i32, + /// The largest desired room extent (in cells) on either axis. The actual + /// extent is capped to what the margin-shrunk leaf can fit. + pub max_size: i32, + /// Cells reserved on every side between the room and its leaf edge. + pub margin: i32, +} + +impl Default for RoomConfig { + /// A sensible default for the v1 dungeon: rooms between 4 and 10 cells on a + /// side, set in one cell from each leaf edge. + fn default() -> Self { + RoomConfig { + min_size: 4, + max_size: 10, + margin: 1, + } + } +} + +/// A room-carving shaper pass (spec §4.7). +/// +/// Turns each placeholder [`Room`](crate::region::RegionKind::Room) region into +/// an actual carved room. Construct one with [`RoomCarver::new`]; it implements +/// [`Pass`] with the stable name `"room_carver"`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RoomCarver { + /// The size/margin limits rooms are carved under. + cfg: RoomConfig, +} + +impl RoomCarver { + /// Creates a room carver with the given configuration. + pub fn new(cfg: RoomConfig) -> Self { + RoomCarver { cfg } + } + + /// Chooses an actual room rectangle inside `leaf`, or [`None`] if `leaf` is + /// too small to hold a `min_size` room after reserving the margin. + /// + /// Randomness is drawn from `rng` in a fixed order — width, height, x-offset, + /// y-offset — so the chosen rect is fully determined by the seed. Returning + /// `None` draws nothing, keeping the per-region stream position predictable + /// (a skipped leaf consumes no randomness). + fn pick_room(&self, leaf: Rect, rng: &mut Rng) -> Option { + let cfg = self.cfg; + + // The placeable area: the leaf shrunk by `margin` on every side. A + // negative or zero result means there is no interior to place a room in. + let inner = leaf.inflate(-cfg.margin); + if inner.w < cfg.min_size || inner.h < cfg.min_size { + return None; + } + + // Cap the desired max to what the inner area can actually hold, then draw + // a size in `[min_size, cap]` inclusive (`range` is half-open, hence + 1). + let max_w = cfg.max_size.min(inner.w); + let max_h = cfg.max_size.min(inner.h); + let w = rng.range(cfg.min_size, max_w + 1); + let h = rng.range(cfg.min_size, max_h + 1); + + // Slide the room to a random position within the inner area. The offset + // ranges keep the whole room inside `inner` (and therefore inside the + // leaf with the margin intact). + let x = rng.range(inner.x, inner.x + (inner.w - w) + 1); + let y = rng.range(inner.y, inner.y + (inner.h - h) + 1); + + Some(Rect::new(x, y, w, h)) + } +} + +impl Pass for RoomCarver { + fn name(&self) -> &str { + "room_carver" + } + + fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) { + // Visit regions in index (id) order so the draw sequence is deterministic + // and the id-as-index invariant is untouched (we mutate in place, never + // add or remove). Only existing Room placeholders are shaped. + for idx in 0..ctx.regions.len() { + if ctx.regions[idx].kind != RegionKind::Room { + continue; + } + let leaf = ctx.regions[idx].bounds; + + let Some(room) = self.pick_room(leaf, rng) else { + // Leaf too small: leave the placeholder in place with empty + // cells. Never removed — that would break RegionId-as-index. + continue; + }; + + // Carve Floor over the room rect. Writes route through the bounds-safe + // accessor, so any cell outside the grid is silently skipped. + for p in room.iter() { + ctx.tiles.set(p, Tile::Floor); + } + + // Finalize the region: bounds become the actual room rect, and cells + // become exactly the room interior floor cells (row-major order). + // DoorPlacer relies on `cells` being the room interior only. + let region = &mut ctx.regions[idx]; + region.bounds = room; + region.cells = room.iter().collect(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::blackboard::Blackboard; + use crate::geometry::Point; + use crate::grid::Grid; + use crate::region::{ConnGraph, Region, RegionId}; + + /// Builds a `GenContext` in this pass's required precondition: an all-`Wall` + /// grid plus a set of placeholder `Room` regions (bounds = leaf rect, empty + /// cells), exactly the state `BspPartition` would leave. Constructed by hand + /// so the test depends on no other pass. + fn ctx_with_leaves(width: u32, height: u32, leaves: &[Rect]) -> GenContext { + let mut ctx = GenContext { + tiles: Grid::new(width, height, Tile::Wall), + regions: Vec::new(), + graph: ConnGraph::new(), + blackboard: Blackboard::new(), + }; + for &leaf in leaves { + ctx.add_region(RegionKind::Room, leaf, Vec::new()); + } + ctx + } + + /// Runs `RoomCarver` with the given config/seed over `ctx`, keyed exactly as + /// the pipeline would (`"room_carver#0"` sub-stream). + fn run(ctx: &mut GenContext, cfg: RoomConfig, seed: u64) { + let mut rng = Rng::from_seed(seed).fork("room_carver#0"); + RoomCarver::new(cfg).apply(ctx, &mut rng); + } + + /// The pass identifies itself with the exact contract name. + #[test] + fn name_is_room_carver() { + assert_eq!(RoomCarver::new(RoomConfig::default()).name(), "room_carver"); + } + + /// Each carved room's bounds lie within its originating leaf and within the + /// map, and the floor cell count equals the bounds area. + #[test] + fn carved_rooms_fit_leaves_and_match_floor_count() { + let cfg = RoomConfig::default(); + let leaves = [Rect::new(0, 0, 20, 16), Rect::new(20, 0, 20, 16)]; + let mut ctx = ctx_with_leaves(40, 16, &leaves); + run(&mut ctx, cfg, 0xABCD); + + let map = Rect::new(0, 0, 40, 16); + for (region, &leaf) in ctx.regions.iter().zip(leaves.iter()) { + let b = region.bounds; + // Non-empty: with these generous leaves no room is skipped. + assert!(!region.cells.is_empty(), "expected a carved room for {leaf:?}"); + + // Room rect sits inside its leaf. + assert!( + b.x >= leaf.x && b.y >= leaf.y && b.right() <= leaf.right() && b.bottom() <= leaf.bottom(), + "room {b:?} escapes its leaf {leaf:?}" + ); + // ...and inside the map. + assert!( + b.x >= 0 && b.y >= 0 && b.right() <= map.right() && b.bottom() <= map.bottom(), + "room {b:?} escapes the map {map:?}" + ); + + // The cell set is exactly the bounds area, all distinct, all carved. + assert_eq!(region.cells.len() as i32, b.w * b.h, "cell count must equal area"); + let mut unique = region.cells.clone(); + unique.sort_by_key(|p| (p.y, p.x)); + unique.dedup(); + assert_eq!(unique.len(), region.cells.len(), "cells must be distinct"); + for &p in ®ion.cells { + assert!(b.contains(p), "cell {p:?} outside bounds {b:?}"); + assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor), "cell {p:?} must be Floor"); + } + } + } + + /// The carved size and margin are respected: each room dimension stays within + /// `[min_size, max_size]` and the room keeps `margin` cells from every leaf + /// edge (so no floor extends into the margin or outside the leaf). + #[test] + fn size_and_margin_are_respected() { + let cfg = RoomConfig { + min_size: 3, + max_size: 6, + margin: 2, + }; + let leaves = [Rect::new(2, 2, 18, 18)]; + let mut ctx = ctx_with_leaves(24, 24, &leaves); + run(&mut ctx, cfg, 0x1234_5678); + + let leaf = leaves[0]; + let b = ctx.regions[0].bounds; + + // Size within configured limits. + assert!(b.w >= cfg.min_size && b.w <= cfg.max_size, "width {} out of range", b.w); + assert!(b.h >= cfg.min_size && b.h <= cfg.max_size, "height {} out of range", b.h); + + // Margin honored on every side: the room sits inside the deflated leaf. + let inner = leaf.inflate(-cfg.margin); + assert!( + b.x >= inner.x && b.y >= inner.y && b.right() <= inner.right() && b.bottom() <= inner.bottom(), + "room {b:?} violates margin (inner area {inner:?})" + ); + + // No Floor tile lies outside the leaf (margin keeps the carve interior). + for (p, &t) in ctx.tiles.iter() { + if t == Tile::Floor { + assert!(leaf.contains(p), "floor at {p:?} escaped leaf {leaf:?}"); + } + } + } + + /// A leaf smaller than `min_size` (after the margin) is skipped gracefully: + /// its region stays in place with empty cells, no tile is carved, and the + /// id-as-index invariant is preserved. No panic. + #[test] + fn undersized_leaf_is_skipped_without_panic() { + let cfg = RoomConfig { + min_size: 6, + max_size: 10, + margin: 1, + }; + // 4x4 leaf: after a 1-cell margin the interior is 2x2 < min_size 6. + let leaves = [Rect::new(1, 1, 4, 4)]; + let mut ctx = ctx_with_leaves(8, 8, &leaves); + run(&mut ctx, cfg, 7); + + // Region preserved, untouched, still a placeholder. + assert_eq!(ctx.regions.len(), 1, "region must not be removed"); + assert_eq!(ctx.regions[0].id, RegionId(0), "id-as-index preserved"); + assert_eq!(ctx.regions[0].kind, RegionKind::Room); + assert_eq!(ctx.regions[0].bounds, leaves[0], "skipped leaf keeps its bounds"); + assert!(ctx.regions[0].cells.is_empty(), "skipped leaf carries no cells"); + + // Nothing was carved. + assert!( + ctx.tiles.iter().all(|(_, &t)| t == Tile::Wall), + "an undersized leaf must carve no floor" + ); + } + + /// A mix of carvable and undersized leaves: the carvable ones become rooms, + /// the undersized one is skipped, and every region is preserved at its index. + #[test] + fn mixed_leaves_carve_some_skip_others() { + let cfg = RoomConfig { + min_size: 4, + max_size: 8, + margin: 1, + }; + let leaves = [ + Rect::new(0, 0, 16, 16), // big: carved + Rect::new(16, 0, 4, 4), // tiny: skipped + Rect::new(0, 16, 16, 16), // big: carved + ]; + let mut ctx = ctx_with_leaves(32, 32, &leaves); + run(&mut ctx, cfg, 0xFEED); + + assert_eq!(ctx.regions.len(), 3, "all regions preserved"); + assert!(!ctx.regions[0].cells.is_empty(), "leaf 0 should be carved"); + assert!(ctx.regions[1].cells.is_empty(), "leaf 1 (tiny) should be skipped"); + assert!(!ctx.regions[2].cells.is_empty(), "leaf 2 should be carved"); + for (i, r) in ctx.regions.iter().enumerate() { + assert_eq!(r.id, RegionId(i), "id must equal index"); + } + } + + /// Determinism: the same seed/sub-stream reproduces identical carved rooms — + /// same bounds, same cells, same tile grid. + #[test] + fn same_seed_yields_identical_rooms() { + let cfg = RoomConfig::default(); + let leaves = [ + Rect::new(0, 0, 20, 20), + Rect::new(20, 0, 20, 20), + Rect::new(0, 20, 20, 20), + ]; + + let mut a = ctx_with_leaves(40, 40, &leaves); + let mut b = ctx_with_leaves(40, 40, &leaves); + run(&mut a, cfg, 0x5EED); + run(&mut b, 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 room rects"); + + let cells_a: Vec<&Vec> = a.regions.iter().map(|r| &r.cells).collect(); + let cells_b: Vec<&Vec> = b.regions.iter().map(|r| &r.cells).collect(); + assert_eq!(cells_a, cells_b, "same seed must reproduce the same cells"); + + assert_eq!(a.tiles, b.tiles, "same seed must reproduce the same tile grid"); + } + + /// Different seeds (overwhelmingly) carve different rooms. + #[test] + fn distinct_seeds_diverge() { + let cfg = RoomConfig::default(); + let leaves = [Rect::new(0, 0, 24, 24), Rect::new(24, 0, 24, 24)]; + + let mut a = ctx_with_leaves(48, 24, &leaves); + let mut b = ctx_with_leaves(48, 24, &leaves); + run(&mut a, cfg, 1); + run(&mut b, 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); + } + + /// Bounds-safety: a leaf whose rect extends past the grid edge must not panic. + /// Only the in-grid portion is carved; cells are still exactly the room rect. + #[test] + fn leaf_partly_off_grid_does_not_panic() { + let cfg = RoomConfig { + min_size: 4, + max_size: 8, + margin: 1, + }; + // Grid is 8x8, but the leaf claims x:4..16 — half of any room may fall + // outside the grid. The carve must clip silently. + let leaves = [Rect::new(4, 0, 12, 8)]; + let mut ctx = ctx_with_leaves(8, 8, &leaves); + run(&mut ctx, cfg, 0xDEAD); + + let r = &ctx.regions[0]; + // The room rect is recorded faithfully even where it spills off-grid. + assert_eq!(r.cells.len() as i32, r.bounds.w * r.bounds.h); + // Every in-grid cell of the room rect is Floor; off-grid writes were + // no-ops, so no panic and no spurious Floor outside the grid. + for &p in &r.cells { + if ctx.tiles.in_bounds(p) { + assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor)); + } + } + } + + /// Non-Room regions are ignored entirely (this pass only shapes Rooms). + #[test] + fn non_room_regions_are_left_untouched() { + let mut ctx = GenContext { + tiles: Grid::new(24, 24, Tile::Wall), + regions: Vec::new(), + graph: ConnGraph::new(), + blackboard: Blackboard::new(), + }; + ctx.add_region(RegionKind::Room, Rect::new(0, 0, 16, 16), Vec::new()); + let corridor_bounds = Rect::new(16, 0, 8, 2); + ctx.regions.push(Region { + id: RegionId(1), + kind: RegionKind::Corridor, + bounds: corridor_bounds, + cells: vec![Point::new(16, 0)], + }); + + run(&mut ctx, RoomConfig::default(), 99); + + // The corridor region is unchanged. + assert_eq!(ctx.regions[1].kind, RegionKind::Corridor); + assert_eq!(ctx.regions[1].bounds, corridor_bounds); + assert_eq!(ctx.regions[1].cells, vec![Point::new(16, 0)]); + // The room region was carved. + assert!(!ctx.regions[0].cells.is_empty()); + } +}