reikhelm/reikhelm-core/src/recipes/dungeon.rs
Parley Hatch e7e8bb9187 feat(core): water & lava pools (PoolDecorator) + lava lighting
Seventh pass (before pillars): floods an organic blob of Water or Lava into
some larger rooms via randomized BFS growth.

- New Tile::Water / Tile::Lava (impassable hazards). Exhaustive matches updated
  (ascii '~'/'!', frozen viz colors, wasm codes 4/5).
- PoolDecorator/PoolConfig (water_chance, lava_chance, min_room): blob is
  interior-only (>= 2 from edge, never the center), and a connectivity guard
  verifies the room's remaining floor stays 4-connected with the center intact
  before committing — so a pool never orphans part of a room.
- DungeonConfig gains `pools`; recipe inserts PoolDecorator after DoorPlacer.
- Renderer: water as cool reflective pools; lava as molten rock that ALSO
  throws warm light onto nearby floor (BFS light field, wall-blocked) — lava
  chambers visibly glow. New water/lava sliders.

Core: 125 tests green (4 new: interior placement, floor stays connected,
zero/small-room skip, determinism); connectivity holds with pools in the
default recipe. clippy clean (core + viz).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:41:48 -06:00

616 lines
24 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
//! → PoolDecorator → PillarPlacer
//! ```
//!
//! 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).
//! 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).
//!
//! ## 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, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect,
PillarConfig, PillarPlacer, PoolConfig, PoolDecorator, RoomCarver, RoomConfig, ShapeWeights,
};
/// 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 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,
}
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 ~1518 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(),
},
connect: ConnectConfig {
extra_edge_ratio: 0.30,
},
doors: DoorConfig::default(),
pools: PoolConfig::default(),
pillars: PillarConfig::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))
.then(PoolDecorator::new(cfg.pools))
.then(PillarPlacer::new(cfg.pillars));
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; 7] = [
"bsp_partition",
"room_carver",
"mst_connect",
"corridor_carver",
"door_placer",
"pool_decorator",
"pillar_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(), 7, "one snapshot per pass (seven 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,
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: 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());
}
}