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 pass;
|
||||
pub mod passes;
|
||||
pub mod recipes;
|
||||
pub mod region;
|
||||
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