Adds RegionTheme + RoomThemer: a pure classifier (no tile changes) that labels each room (terrain-driven Forge/Cistern/Hall, special Throne/ Threshold, weighted Crypt/Den/Library/Vault/Stone). Renderer tints themed rooms; EntityPlacer biases contents by theme. Entrance/exit now chosen RNG-free from geometry so themer & placer agree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
726 lines
29 KiB
Rust
726 lines
29 KiB
Rust
//! 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 → CaveShaper → MstConnect → CorridorCarver
|
||
//! → DoorPlacer → PoolDecorator → PillarPlacer → RoomThemer → EntityPlacer
|
||
//! ```
|
||
//!
|
||
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
|
||
//! Room region per leaf).
|
||
//! 2. [`RoomCarver`] carves an actual room inside each leaf, after which
|
||
//! [`CaveShaper`] re-sculpts some rooms into organic cellular-automata caves
|
||
//! (each a single connected blob; the rest stay clean polygons).
|
||
//! 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).
|
||
//! 6. [`PoolDecorator`] floods organic water/lava pools into some larger rooms
|
||
//! (a connectivity-guarded decorator — never orphans part of a room).
|
||
//! 7. [`PillarPlacer`] scatters free-standing pillars into larger rooms (a
|
||
//! finishing decorator; isolated obstacles that never break connectivity).
|
||
//! 8. [`RoomThemer`] reads the finished terrain and labels each room with a
|
||
//! [`RegionTheme`](crate::region::RegionTheme) — a pure classifier that
|
||
//! changes no tiles, driving the renderer's tint and the entity biases.
|
||
//! 9. [`EntityPlacer`] populates the dungeon — an entrance, a far-off exit, and
|
||
//! scattered treasure and monsters — as an overlay layer (no terrain change).
|
||
//!
|
||
//! ## 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 serde::{Deserialize, Serialize};
|
||
|
||
use crate::pass::Pipeline;
|
||
use crate::passes::{
|
||
BspConfig, BspPartition, CaveConfig, CaveShaper, ConnectConfig, CorridorCarver, DoorConfig,
|
||
DoorPlacer, EntityConfig, EntityPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig,
|
||
PoolDecorator, RoomCarver, RoomConfig, RoomThemer, ShapeWeights, ThemeConfig,
|
||
};
|
||
|
||
/// 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, Serialize, Deserialize)]
|
||
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 some rooms are re-sculpted into organic cellular-automata caves.
|
||
pub caves: CaveConfig,
|
||
/// How rooms are linked into a connectivity graph.
|
||
pub connect: ConnectConfig,
|
||
/// How room↔corridor pierce points are marked as doors.
|
||
pub doors: DoorConfig,
|
||
/// How water/lava pools are flooded into larger rooms (decorator).
|
||
pub pools: PoolConfig,
|
||
/// How free-standing pillars are scattered into larger rooms (decorator).
|
||
pub pillars: PillarConfig,
|
||
/// The relative weights of the plain (non-terrain) room themes (classifier).
|
||
pub themes: ThemeConfig,
|
||
/// How the dungeon is populated with entities (entrance, exit, treasure,
|
||
/// monsters).
|
||
pub entities: EntityConfig,
|
||
}
|
||
|
||
impl Default for DungeonConfig {
|
||
/// A recognizable, interconnected multi-room dungeon on a 64×40 canvas.
|
||
///
|
||
/// The recipe deliberately picks a richer flavor than the passes' own
|
||
/// primitive defaults: a denser partition with larger rooms (`min_leaf 8`,
|
||
/// `max_depth 5`, rooms `5..=11`) yields ~15–18 rooms, and
|
||
/// `extra_edge_ratio 0.30` adds loop edges so the connectivity graph isn't a
|
||
/// strict tree — a pure MST (the `ConnectConfig` primitive default) reads as a
|
||
/// linear chain of rooms, which is duller to explore. `door_chance` stays at
|
||
/// 0.5 (an even mix of doors and open archways).
|
||
///
|
||
/// These stay consistent so RoomCarver never skips a leaf: the validation
|
||
/// inequality `min_leaf - 2*margin >= min_size` holds with room to spare
|
||
/// (`8 - 2 >= 5`). See [`validate`](DungeonConfig::validate).
|
||
fn default() -> Self {
|
||
DungeonConfig {
|
||
width: 64,
|
||
height: 40,
|
||
bsp: BspConfig {
|
||
min_leaf: 8,
|
||
max_depth: 5,
|
||
},
|
||
rooms: RoomConfig {
|
||
min_size: 5,
|
||
max_size: 11,
|
||
margin: 1,
|
||
shapes: ShapeWeights::varied(),
|
||
},
|
||
caves: CaveConfig::default(),
|
||
connect: ConnectConfig {
|
||
extra_edge_ratio: 0.30,
|
||
},
|
||
doors: DoorConfig::default(),
|
||
pools: PoolConfig::default(),
|
||
pillars: PillarConfig::default(),
|
||
themes: ThemeConfig::default(),
|
||
entities: EntityConfig::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(CaveShaper::new(cfg.caves))
|
||
.then(MstConnect::new(cfg.connect))
|
||
.then(CorridorCarver::new())
|
||
.then(DoorPlacer::new(cfg.doors))
|
||
.then(PoolDecorator::new(cfg.pools))
|
||
.then(PillarPlacer::new(cfg.pillars))
|
||
.then(RoomThemer::new(cfg.themes))
|
||
.then(EntityPlacer::new(cfg.entities));
|
||
|
||
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 pass names in pipeline order, used to check snapshot labels.
|
||
const PASS_NAMES: [&str; 10] = [
|
||
"bsp_partition",
|
||
"room_carver",
|
||
"cave_shaper",
|
||
"mst_connect",
|
||
"corridor_carver",
|
||
"door_placer",
|
||
"pool_decorator",
|
||
"pillar_placer",
|
||
"room_themer",
|
||
"entity_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(), 10, "one snapshot per pass (ten 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 ten 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,
|
||
shapes: ShapeWeights::default(),
|
||
},
|
||
..DungeonConfig::default()
|
||
};
|
||
assert_eq!(dungeon(cfg).err(), Some(ConfigError::RoomLargerThanLeaf));
|
||
}
|
||
|
||
/// The exact boundary of the inequality is accepted: a config where
|
||
/// `min_leaf - 2*margin == min_size` (the tightest legal fit) validates Ok,
|
||
/// guarding the `>=` (not `>`) in the check. The shipped default sits safely
|
||
/// inside this bound (`8 - 2 >= 5`), so we build a boundary config explicitly.
|
||
#[test]
|
||
fn boundary_config_is_accepted() {
|
||
// 6 - 2*1 == 4 == min_size: exactly on the edge, must be Ok.
|
||
let boundary = DungeonConfig {
|
||
bsp: BspConfig {
|
||
min_leaf: 6,
|
||
max_depth: 4,
|
||
},
|
||
rooms: RoomConfig {
|
||
min_size: 4,
|
||
max_size: 10,
|
||
margin: 1,
|
||
shapes: ShapeWeights::default(),
|
||
},
|
||
..DungeonConfig::default()
|
||
};
|
||
assert!(boundary.validate().is_ok());
|
||
assert!(dungeon(boundary).is_ok());
|
||
// The shipped default validates too.
|
||
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,
|
||
shapes: ShapeWeights::default(),
|
||
},
|
||
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,
|
||
shapes: ShapeWeights::default(),
|
||
},
|
||
..DungeonConfig::default()
|
||
};
|
||
assert_eq!(dungeon(tiny).err(), Some(ConfigError::RoomTooSmall));
|
||
|
||
let neg_margin = DungeonConfig {
|
||
rooms: RoomConfig {
|
||
min_size: 2,
|
||
max_size: 4,
|
||
margin: -1,
|
||
shapes: ShapeWeights::default(),
|
||
},
|
||
..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: entities ---------------------------------------------------
|
||
|
||
/// The populated default dungeon has exactly one entrance and one exit across
|
||
/// many seeds, and every entity sits on a walkable Floor cell (never a wall,
|
||
/// door, pool, or pillar).
|
||
#[test]
|
||
fn default_dungeon_is_populated_with_entities() {
|
||
use crate::entity::EntityKind;
|
||
let cfg = DungeonConfig::default();
|
||
for seed in 1..=16u64 {
|
||
let map = dungeon(cfg).unwrap().run(seed);
|
||
let entrances = map.entities.iter().filter(|e| e.kind == EntityKind::Entrance).count();
|
||
let exits = map.entities.iter().filter(|e| e.kind == EntityKind::Exit).count();
|
||
assert_eq!(entrances, 1, "seed {seed}: expected exactly one entrance");
|
||
assert_eq!(exits, 1, "seed {seed}: expected exactly one exit");
|
||
for e in &map.entities {
|
||
assert_eq!(
|
||
map.tiles.get(e.at),
|
||
Some(&Tile::Floor),
|
||
"seed {seed}: entity {e:?} must sit on Floor"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Test 7b: room themes -----------------------------------------------
|
||
|
||
/// Every real room is themed (none left `None`); corridors are never themed;
|
||
/// each dungeon has *at most* one Throne and one Threshold (the exit/entrance
|
||
/// rooms — unless terrain outranks them); and across many seeds both special
|
||
/// themes do appear.
|
||
#[test]
|
||
fn default_dungeon_themes_every_room_with_special_rooms() {
|
||
use crate::region::RegionTheme;
|
||
let cfg = DungeonConfig::default();
|
||
let (mut total_throne, mut total_threshold) = (0usize, 0usize);
|
||
for seed in 1..=24u64 {
|
||
let map = dungeon(cfg).unwrap().run(seed);
|
||
|
||
for room in real_rooms(&map) {
|
||
assert!(
|
||
room.theme.is_some(),
|
||
"seed {seed}: real room {:?} was left unthemed",
|
||
room.id
|
||
);
|
||
}
|
||
// Corridors carry no theme.
|
||
for r in map.regions.iter().filter(|r| r.kind == RegionKind::Corridor) {
|
||
assert_eq!(r.theme, None, "seed {seed}: corridor {:?} was themed", r.id);
|
||
}
|
||
|
||
let count = |t: RegionTheme| real_rooms(&map).iter().filter(|r| r.theme == Some(t)).count();
|
||
// Throne/Threshold mark single rooms — terrain may pre-empt them
|
||
// (a lava exit reads Forge), so the count is 0 or 1, never more.
|
||
assert!(count(RegionTheme::Throne) <= 1, "seed {seed}: more than one Throne");
|
||
assert!(count(RegionTheme::Threshold) <= 1, "seed {seed}: more than one Threshold");
|
||
total_throne += count(RegionTheme::Throne);
|
||
total_threshold += count(RegionTheme::Threshold);
|
||
}
|
||
assert!(total_throne > 0, "no Throne appeared across 24 seeds");
|
||
assert!(total_threshold > 0, "no Threshold appeared across 24 seeds");
|
||
}
|
||
|
||
/// A room flooded with lava is always a Forge, and one with water a Cistern —
|
||
/// the terrain→theme correspondence the renderer relies on. We scan seeds with
|
||
/// pools forced on until we see each, so the assertion is a real guard.
|
||
#[test]
|
||
fn lava_and_water_rooms_are_forge_and_cistern() {
|
||
use crate::region::RegionTheme;
|
||
let cfg = DungeonConfig {
|
||
pools: crate::passes::PoolConfig { water_chance: 0.6, lava_chance: 0.6, min_room: 7 },
|
||
..DungeonConfig::default()
|
||
};
|
||
for seed in 1..=24u64 {
|
||
let map = dungeon(cfg).unwrap().run(seed);
|
||
for room in real_rooms(&map) {
|
||
let has = |t: Tile| room.cells.iter().any(|&c| map.tiles.get(c) == Some(&t));
|
||
if has(Tile::Lava) {
|
||
assert_eq!(room.theme, Some(RegionTheme::Forge), "lava room must be a Forge");
|
||
} else if has(Tile::Water) {
|
||
assert_eq!(room.theme, Some(RegionTheme::Cistern), "water room must be a Cistern");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Test 8: 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());
|
||
}
|
||
}
|