feat(core): CorridorCarver pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-28 21:36:04 -06:00
parent d3e389d7f8
commit 42fda84eaa
2 changed files with 464 additions and 0 deletions

View file

@ -0,0 +1,461 @@
//! The [`CorridorCarver`] connector pass (spec §4.7).
//!
//! A *connector* in its carving role: `MstConnect` decides **which** rooms link
//! up (the [`Edge`](crate::region::Edge)s in `ctx.graph`); this pass makes those
//! links physical by carving a corridor of [`Floor`](crate::map::Tile::Floor)
//! between each edge's two room centers.
//!
//! Per the inter-pass data contract (see [`crate::passes`]), for every edge in
//! `ctx.graph` this pass:
//!
//! 1. Looks up the two endpoint [`Room`](crate::region::RegionKind::Room)
//! regions (an edge endpoint [`RegionId`](crate::region::RegionId) is its
//! index in `ctx.regions`) and takes each room's
//! [`center`](crate::geometry::Rect::center).
//! 2. Carves an **L-shaped** corridor between the two centers: one horizontal run
//! and one vertical run joined at an elbow. Which leg comes first (horizontal
//! then vertical, or vertical then horizontal) is chosen from `rng`, so the
//! elbow's side varies with the seed. Both legs are walked with
//! [`Line::cells`](crate::geometry::Line::cells) and written through the
//! bounds-safe [`Grid::set`](crate::grid::Grid::set) — any cell outside the
//! grid is silently skipped, never a panic (spec §8).
//! 3. Appends one [`Corridor`](crate::region::RegionKind::Corridor) region whose
//! `cells` are exactly the carved corridor cells (in carve order, the elbow
//! counted once) and whose `bounds` is their axis-aligned bounding box.
//!
//! Carving over a cell that is already `Floor` (a room interior, or where two
//! corridors cross) is fine — corridors may pass through or merge.
//!
//! This pass **does not** set [`Door`](crate::map::Tile::Door) tiles and **does
//! not** touch any `edge.at`; that is `DoorPlacer`'s job. It reads `ctx.graph`
//! and the Room region centers and writes `ctx.tiles` plus one Corridor region
//! per edge.
//!
//! ## Determinism (spec §7)
//!
//! Edges are processed in `ctx.graph`'s insertion order (the order
//! [`ConnGraph::edges`](crate::region::ConnGraph::edges) yields), and the only
//! randomness is the per-edge leg-order coin flip drawn from the passed `rng` in
//! that same order. Nothing depends on `HashMap`/`HashSet` iteration. Given the
//! same `rng` sub-stream and the same graph + rooms, the carved tiles and
//! Corridor regions are byte-identical on every machine. Its tests construct the
//! precondition (rooms + a graph) directly, so this pass depends on no other
//! pass's code.
use crate::geometry::{Line, Point, Rect};
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
use crate::region::RegionKind;
use crate::rng::Rng;
/// A corridor-carving connector pass (spec §4.7).
///
/// Carves an L-shaped [`Floor`](crate::map::Tile::Floor) corridor for every
/// edge in `ctx.graph`, joining the two rooms' centers, and records each as a
/// [`Corridor`](crate::region::RegionKind::Corridor) region. Construct one with
/// [`CorridorCarver::new`] (or [`Default`]); it implements [`Pass`] with the
/// stable name `"corridor_carver"`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct CorridorCarver;
impl CorridorCarver {
/// Creates a corridor carver.
pub fn new() -> Self {
CorridorCarver
}
}
impl Pass for CorridorCarver {
fn name(&self) -> &str {
"corridor_carver"
}
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
// Snapshot each edge's two endpoint centers up front, in graph insertion
// order. We read both `ctx.graph` and `ctx.regions` here, then drop those
// borrows before carving — so the carve loop is free to call
// `ctx.add_region` (which needs `&mut ctx`) without a borrow conflict.
//
// An endpoint RegionId is its index in `ctx.regions` (the id-as-index
// invariant). A missing or non-Room endpoint yields `None` and that edge
// is skipped, keeping the pass total even on a malformed graph.
let endpoints: Vec<(Point, Point)> = ctx
.graph
.edges()
.iter()
.filter_map(|edge| {
let a = room_center(ctx, edge.a)?;
let b = room_center(ctx, edge.b)?;
Some((a, b))
})
.collect();
for (from, to) in endpoints {
// Choose which leg leads: a horizontal-first L bends at (to.x, from.y),
// a vertical-first L bends at (from.x, to.y). The coin flip is the only
// randomness, drawn once per edge in graph order for determinism.
let horizontal_first = rng.chance(0.5);
let elbow = if horizontal_first {
Point::new(to.x, from.y)
} else {
Point::new(from.x, to.y)
};
// Walk both legs (from -> elbow -> to), carving Floor and recording
// each carved cell once. The elbow is the last cell of the first leg
// and the first cell of the second, so we skip the second leg's
// leading cell to avoid duplicating it.
let mut cells: Vec<Point> = Vec::new();
for p in Line::new(from, elbow).cells() {
carve(ctx, p, &mut cells);
}
for p in Line::new(elbow, to).cells().skip(1) {
carve(ctx, p, &mut cells);
}
// Record the corridor region (one per edge). Bounds is the AABB of the
// carved cells; an empty cell set (degenerate) gets an empty rect.
let bounds = bounding_rect(&cells);
ctx.add_region(RegionKind::Corridor, bounds, cells);
}
}
}
/// Returns the center of the `Room` region with id `index`, or [`None`] if the
/// id is out of range or names a non-Room region.
///
/// Endpoint ids come from `MstConnect`, which only ever links real rooms, so in
/// the assembled recipe this always resolves; the guard simply keeps the pass
/// total against a hand-built or malformed graph.
fn room_center(ctx: &GenContext, index: crate::region::RegionId) -> Option<Point> {
let region = ctx.regions.get(index.0)?;
(region.kind == RegionKind::Room).then(|| region.bounds.center())
}
/// Carves [`Floor`](Tile::Floor) at `p` (a bounds-safe no-op if `p` is off-grid)
/// and records `p` as a corridor cell.
///
/// The cell is recorded whether or not it was already `Floor`, so a corridor's
/// `cells` faithfully describe the full L-path even where it overlaps a room or
/// another corridor. We push regardless of in-bounds-ness so `cells` matches the
/// geometric path; downstream consumers already treat off-grid cells as no-ops.
fn carve(ctx: &mut GenContext, p: Point, cells: &mut Vec<Point>) {
ctx.tiles.set(p, Tile::Floor);
cells.push(p);
}
/// Computes the axis-aligned bounding box enclosing every point in `cells`.
///
/// Returns an empty rect (`w == h == 0`) when `cells` is empty, so the result is
/// always well-formed. For one cell the rect is a 1x1 box at that cell.
fn bounding_rect(cells: &[Point]) -> Rect {
let Some(&first) = cells.first() else {
return Rect::new(0, 0, 0, 0);
};
let (mut min_x, mut min_y) = (first.x, first.y);
let (mut max_x, mut max_y) = (first.x, first.y);
for &p in &cells[1..] {
min_x = min_x.min(p.x);
min_y = min_y.min(p.y);
max_x = max_x.max(p.x);
max_y = max_y.max(p.y);
}
// +1 on each extent: bounds are inclusive of both the min and max cell.
Rect::new(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blackboard::Blackboard;
use crate::grid::Grid;
use crate::region::{ConnGraph, RegionId, RegionKind};
use std::collections::{BTreeSet, VecDeque};
/// Builds a `GenContext` in this pass's required precondition: an all-`Wall`
/// grid with a set of *real* Room regions (each a carved Floor rect) plus a
/// `ConnGraph` whose edges name those rooms by id. Constructed by hand so the
/// test depends on no other pass.
///
/// `rooms` are `(x, y, w, h)` rects carved to Floor; `edges` are
/// `(room_index, room_index)` pairs added to the graph in order.
fn ctx_with_rooms_and_edges(
width: u32,
height: u32,
rooms: &[(i32, i32, i32, i32)],
edges: &[(usize, usize)],
) -> GenContext {
let mut ctx = GenContext {
tiles: Grid::new(width, height, Tile::Wall),
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
for &(x, y, w, h) in rooms {
let bounds = Rect::new(x, y, w, h);
let cells: Vec<Point> = bounds.iter().collect();
// Carve the room's Floor so room centers start as Floor, exactly as
// RoomCarver would leave them.
for p in bounds.iter() {
ctx.tiles.set(p, Tile::Floor);
}
ctx.add_region(RegionKind::Room, bounds, cells);
}
for &(a, b) in edges {
ctx.graph.add_edge(RegionId(a), RegionId(b));
}
ctx
}
/// Runs `CorridorCarver` over `ctx`, keyed exactly as the pipeline would
/// (`"corridor_carver#0"` sub-stream).
fn run(ctx: &mut GenContext, seed: u64) {
let mut rng = Rng::from_seed(seed).fork("corridor_carver#0");
CorridorCarver::new().apply(ctx, &mut rng);
}
/// Flood-fills the 4-connected Floor component containing `start` and reports
/// whether `goal` is in it. Used to verify two rooms are floor-connected.
fn floor_connected(tiles: &Grid<Tile>, start: Point, goal: Point) -> bool {
if tiles.get(start) != Some(&Tile::Floor) || tiles.get(goal) != Some(&Tile::Floor) {
return false;
}
let mut seen: BTreeSet<(i32, i32)> = BTreeSet::new();
let mut queue: VecDeque<Point> = VecDeque::new();
seen.insert((start.x, start.y));
queue.push_back(start);
while let Some(p) = queue.pop_front() {
if p == goal {
return true;
}
for n in tiles.neighbors4(p) {
if tiles.get(n) == Some(&Tile::Floor) && seen.insert((n.x, n.y)) {
queue.push_back(n);
}
}
}
false
}
/// The pass identifies itself with the exact contract name.
#[test]
fn name_is_corridor_carver() {
assert_eq!(CorridorCarver::new().name(), "corridor_carver");
}
/// Given two rooms and one edge between them, after running the two room
/// centers lie in the same connected Floor component (the L-corridor bridges
/// them).
#[test]
fn one_edge_connects_two_room_centers() {
// Two 4x4 rooms with a wall gap between them, one edge linking them.
let rooms = [(2, 2, 4, 4), (16, 12, 4, 4)];
let mut ctx = ctx_with_rooms_and_edges(24, 24, &rooms, &[(0, 1)]);
let c0 = ctx.regions[0].bounds.center();
let c1 = ctx.regions[1].bounds.center();
// Before carving they are in separate components.
assert!(!floor_connected(&ctx.tiles, c0, c1));
run(&mut ctx, 0xABCD);
// After carving the centers share one Floor component.
assert!(
floor_connected(&ctx.tiles, c0, c1),
"the corridor must connect the two room centers"
);
}
/// Exactly one Corridor region is added per edge, and every other region is
/// preserved at its index (id-as-index invariant intact).
#[test]
fn one_corridor_region_per_edge() {
let rooms = [(0, 0, 4, 4), (20, 0, 4, 4), (0, 20, 4, 4)];
// Three edges => three corridor regions.
let edges = [(0, 1), (1, 2), (0, 2)];
let mut ctx = ctx_with_rooms_and_edges(24, 24, &rooms, &edges);
run(&mut ctx, 0x1234);
// 3 rooms + 3 corridors.
assert_eq!(ctx.regions.len(), rooms.len() + edges.len());
let corridors: Vec<&_> = ctx
.regions
.iter()
.filter(|r| r.kind == RegionKind::Corridor)
.collect();
assert_eq!(corridors.len(), edges.len(), "one corridor region per edge");
// The rooms are untouched and ids still equal indices.
for (i, r) in ctx.regions.iter().enumerate() {
assert_eq!(r.id, RegionId(i), "id must equal index");
}
for r in &corridors {
assert!(!r.cells.is_empty(), "a carved corridor has cells");
// Every recorded cell is in fact Floor (in-bounds ones at least).
for &p in &r.cells {
if ctx.tiles.in_bounds(p) {
assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor));
}
}
// Bounds enclose every cell.
for &p in &r.cells {
assert!(r.bounds.contains(p), "cell {p:?} escapes bounds {:?}", r.bounds);
}
}
}
/// An L-corridor's cells stay in bounds for interior rooms, and carving never
/// panics even when a room center sits right at the map edge (legs run off the
/// grid and the writes silently clip).
#[test]
fn carving_near_edge_does_not_panic_and_clips() {
// Room A's center is at the very top-left corner; room B near the far
// edge. The L between them hugs the boundary; off-grid writes are no-ops.
let rooms = [(0, 0, 1, 1), (9, 9, 1, 1)];
let mut ctx = ctx_with_rooms_and_edges(10, 10, &rooms, &[(0, 1)]);
run(&mut ctx, 0xDEAD);
// No panic above. Every recorded corridor cell that is in-bounds is Floor,
// and no Floor was written outside the grid (the grid only has in-grid
// cells to check).
let corridor = ctx
.regions
.iter()
.find(|r| r.kind == RegionKind::Corridor)
.expect("one corridor region");
for &p in &corridor.cells {
if ctx.tiles.in_bounds(p) {
assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor));
}
}
}
/// A center literally on the boundary: the run extends off-grid but the carve
/// clips with no panic and still records the geometric path.
#[test]
fn endpoint_outside_carves_without_panic() {
// Room at the top-left 1x1; an edge to a room whose center is also at the
// edge. Then shrink the grid mentally: the legs walk along x=0 / y=0.
let rooms = [(0, 0, 1, 1), (0, 8, 1, 1)];
let mut ctx = ctx_with_rooms_and_edges(9, 9, &rooms, &[(0, 1)]);
// Should not panic.
run(&mut ctx, 1);
// Centers connected: both at x=0, a straight vertical run.
let c0 = ctx.regions[0].bounds.center();
let c1 = ctx.regions[1].bounds.center();
assert!(floor_connected(&ctx.tiles, c0, c1));
}
/// Determinism: the same seed/sub-stream and the same graph + rooms reproduce
/// identical carved tiles and identical Corridor regions.
#[test]
fn same_seed_yields_identical_corridors() {
let rooms = [
(2, 2, 4, 4),
(24, 4, 4, 4),
(6, 26, 4, 4),
(28, 28, 4, 4),
];
let edges = [(0, 1), (1, 3), (0, 2), (2, 3)];
let mut a = ctx_with_rooms_and_edges(36, 36, &rooms, &edges);
let mut b = ctx_with_rooms_and_edges(36, 36, &rooms, &edges);
run(&mut a, 0x5EED);
run(&mut b, 0x5EED);
assert_eq!(a.tiles, b.tiles, "same seed must reproduce the same tile grid");
let regions_a: Vec<_> = a
.regions
.iter()
.filter(|r| r.kind == RegionKind::Corridor)
.map(|r| (r.bounds, r.cells.clone()))
.collect();
let regions_b: Vec<_> = b
.regions
.iter()
.filter(|r| r.kind == RegionKind::Corridor)
.map(|r| (r.bounds, r.cells.clone()))
.collect();
assert_eq!(
regions_a, regions_b,
"same seed must reproduce the same corridor regions"
);
}
/// The leg-order coin flip actually depends on the rng: at least one seed
/// across a sample bends the elbow the other way, proving the randomness is
/// wired in (and not a constant). Both outcomes still connect the rooms.
#[test]
fn leg_order_varies_with_seed() {
let rooms = [(2, 2, 2, 2), (20, 20, 2, 2)];
let mut seen_elbows: BTreeSet<(i32, i32)> = BTreeSet::new();
for seed in 0..32u64 {
let mut ctx = ctx_with_rooms_and_edges(28, 28, &rooms, &[(0, 1)]);
run(&mut ctx, seed);
let corridor = ctx
.regions
.iter()
.find(|r| r.kind == RegionKind::Corridor)
.unwrap();
// The elbow is the bend: identify it as the cell present in the path
// where the run changes axis. Simpler: record the bounds, which differ
// only by which corner the path hugs — but bounds are identical for an
// L either way. Instead detect the elbow via the path's turn cell.
let c0 = rooms[0];
let c1 = rooms[1];
let from = Rect::new(c0.0, c0.1, c0.2, c0.3).center();
let to = Rect::new(c1.0, c1.1, c1.2, c1.3).center();
// Horizontal-first elbow vs vertical-first elbow.
let hf = Point::new(to.x, from.y);
let vf = Point::new(from.x, to.y);
// Whichever elbow is on the carved path is the one chosen this seed.
if corridor.cells.contains(&hf) {
seen_elbows.insert((hf.x, hf.y));
}
if corridor.cells.contains(&vf) {
seen_elbows.insert((vf.x, vf.y));
}
}
assert!(
seen_elbows.len() >= 2,
"across seeds both L orientations should appear (saw {seen_elbows:?})"
);
}
/// A graph with no edges carves nothing and adds no corridor regions.
#[test]
fn no_edges_carves_nothing() {
let rooms = [(2, 2, 4, 4), (16, 16, 4, 4)];
let mut ctx = ctx_with_rooms_and_edges(24, 24, &rooms, &[]);
// Snapshot the room floor before; running must not change tiles.
let before = ctx.tiles.clone();
run(&mut ctx, 7);
assert_eq!(ctx.tiles, before, "no edges => no carving");
assert!(
ctx.regions.iter().all(|r| r.kind == RegionKind::Room),
"no edges => no corridor regions"
);
}
/// This pass sets no Door tiles and leaves every edge's `at` as `None`
/// (DoorPlacer's job, not ours).
#[test]
fn sets_no_doors_and_leaves_edge_at_none() {
let rooms = [(2, 2, 4, 4), (16, 16, 4, 4)];
let mut ctx = ctx_with_rooms_and_edges(24, 24, &rooms, &[(0, 1)]);
run(&mut ctx, 0xBEEF);
// No Door tile anywhere.
assert!(
ctx.tiles.iter().all(|(_, &t)| t != Tile::Door),
"CorridorCarver must not place doors"
);
// Every edge still has at == None.
assert!(
ctx.graph.edges().iter().all(|e| e.at.is_none()),
"CorridorCarver must not touch edge.at"
);
}
}

View file

@ -31,3 +31,6 @@ pub use room::{RoomCarver, RoomConfig};
pub mod connect; pub mod connect;
pub use connect::{ConnectConfig, MstConnect}; pub use connect::{ConnectConfig, MstConnect};
pub mod corridor;
pub use corridor::CorridorCarver;