feat(core): dungeon recipe + end-to-end integration tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-28 22:24:53 -06:00
parent 93c32a652f
commit 4e54d3b7ba
3 changed files with 578 additions and 0 deletions

View file

@ -10,5 +10,6 @@ pub mod grid;
pub mod map; pub mod map;
pub mod pass; pub mod pass;
pub mod passes; pub mod passes;
pub mod recipes;
pub mod region; pub mod region;
pub mod rng; pub mod rng;

View file

@ -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<Pipeline, ConfigError> {
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<Point> = 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());
}
}

View file

@ -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;