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:
parent
93c32a652f
commit
4e54d3b7ba
3 changed files with 578 additions and 0 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
563
reikhelm-core/src/recipes/dungeon.rs
Normal file
563
reikhelm-core/src/recipes/dungeon.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
14
reikhelm-core/src/recipes/mod.rs
Normal file
14
reikhelm-core/src/recipes/mod.rs
Normal 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;
|
||||||
Loading…
Reference in a new issue