reikhelm/reikhelm-core/src/recipes/dungeon.rs
Parley Hatch 4e54d3b7ba feat(core): dungeon recipe + end-to-end integration tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:24:53 -06:00

563 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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());
}
}