From 42fda84eaafc019b6ef4263dfc5db5cfcfd7fff7 Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Thu, 28 May 2026 21:36:04 -0600 Subject: [PATCH] feat(core): CorridorCarver pass Co-Authored-By: Claude Opus 4.8 (1M context) --- reikhelm-core/src/passes/corridor.rs | 461 +++++++++++++++++++++++++++ reikhelm-core/src/passes/mod.rs | 3 + 2 files changed, 464 insertions(+) create mode 100644 reikhelm-core/src/passes/corridor.rs diff --git a/reikhelm-core/src/passes/corridor.rs b/reikhelm-core/src/passes/corridor.rs new file mode 100644 index 0000000..26e0de7 --- /dev/null +++ b/reikhelm-core/src/passes/corridor.rs @@ -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 = 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 { + 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) { + 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 = 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, 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 = 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" + ); + } +} diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index df96638..7d36d19 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -31,3 +31,6 @@ pub use room::{RoomCarver, RoomConfig}; pub mod connect; pub use connect::{ConnectConfig, MstConnect}; + +pub mod corridor; +pub use corridor::CorridorCarver;