feat(core): CorridorCarver pass
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d3e389d7f8
commit
42fda84eaa
2 changed files with 464 additions and 0 deletions
461
reikhelm-core/src/passes/corridor.rs
Normal file
461
reikhelm-core/src/passes/corridor.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,3 +31,6 @@ pub use room::{RoomCarver, RoomConfig};
|
|||
|
||||
pub mod connect;
|
||||
pub use connect::{ConnectConfig, MstConnect};
|
||||
|
||||
pub mod corridor;
|
||||
pub use corridor::CorridorCarver;
|
||||
|
|
|
|||
Loading…
Reference in a new issue