feat(core): RoomCarver pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-28 21:30:28 -06:00
parent f95a562927
commit d2d5fa1a8b
2 changed files with 430 additions and 0 deletions

View file

@ -25,3 +25,6 @@
pub mod bsp;
pub use bsp::{BspConfig, BspPartition};
pub mod room;
pub use room::{RoomCarver, RoomConfig};

View file

@ -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<Rect> {
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 &region.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<Rect> = a.regions.iter().map(|r| r.bounds).collect();
let bounds_b: Vec<Rect> = 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<Point>> = a.regions.iter().map(|r| &r.cells).collect();
let cells_b: Vec<&Vec<Point>> = 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<Rect> = a.regions.iter().map(|r| r.bounds).collect();
let bounds_b: Vec<Rect> = 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());
}
}