feat(core): DoorPlacer pass

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

View file

@ -0,0 +1,480 @@
//! The [`DoorPlacer`] placer pass (spec §4.7).
//!
//! A *placer* drops semantic markers onto an already-carved map. This is the
//! **final** pass of the v1 dungeon recipe: it reads the finished
//! [`Tile`](crate::map::Tile) grid, the [`Region`](crate::region::Region) set,
//! and the connectivity [`ConnGraph`](crate::region::ConnGraph), then converts
//! the [`Wall`](crate::map::Tile::Wall) cells that sit on a room↔corridor
//! threshold into [`Door`](crate::map::Tile::Door) tiles and records a
//! representative door location into each connected edge's `at`.
//!
//! Per the inter-pass data contract (see [`crate::passes`]), this pass:
//!
//! 1. Builds a membership lookup from the regions: which cells are *room floor*
//! (a cell in some [`Room`](crate::region::RegionKind::Room) region's `cells`)
//! and which are *corridor floor* (a cell in some
//! [`Corridor`](crate::region::RegionKind::Corridor) region's `cells`).
//! 2. Scans every cell. A `Wall` cell becomes a `Door` when one of its two
//! *opposite* orthogonal neighbor pairs — `(N, S)` or `(W, E)` — has one side
//! that is a clean room floor and the opposite side that is corridor floor.
//! Requiring the two floors to face each other across the wall is what keeps
//! doors strictly on the threshold and never mid-room or mid-corridor: a wall
//! in open ground, or one with floor on only one side, fails the test.
//! 3. For every edge in `ctx.graph`, records a representative door cell into
//! `edge.at` — the first placed door (in row-major scan order) whose room
//! side belongs to one of the edge's two endpoint rooms.
//!
//! All tile writes go through the bounds-safe
//! [`Grid::set`](crate::grid::Grid::set); all neighbor probing through
//! [`Grid`](crate::grid::Grid) accessors, so a wall on the very edge of the map
//! (with off-grid "neighbors") is handled without panic — an out-of-bounds
//! neighbor simply is not floor (spec §8).
//!
//! ## Determinism (spec §7)
//!
//! Cells are scanned in row-major order via [`Grid::iter`](crate::grid::Grid::iter);
//! membership is resolved through a [`BTreeMap`](std::collections::BTreeMap)
//! keyed by `(x, y)`, never a `HashMap`, so no iteration-order nondeterminism can
//! leak into which cell is chosen as an edge's representative door. This pass
//! draws **no** randomness — door placement is a pure function of the carved
//! geometry — so the passed `rng` is intentionally unused. Its tests build the
//! tiles + regions + graph precondition by hand, depending on no other pass.
use std::collections::BTreeMap;
use crate::geometry::Point;
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
use crate::region::{RegionId, RegionKind};
use crate::rng::Rng;
/// A door-placing placer pass (spec §4.7).
///
/// Converts room↔corridor boundary [`Wall`](crate::map::Tile::Wall) cells into
/// [`Door`](crate::map::Tile::Door)s and fills in each connected edge's `at`.
/// Construct one with [`DoorPlacer::new`] (or [`Default`]); it implements
/// [`Pass`] with the stable name `"door_placer"`. It carries no configuration in
/// v1 — door placement is fully determined by the carved geometry.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DoorPlacer;
impl DoorPlacer {
/// Creates a door placer.
pub fn new() -> Self {
DoorPlacer
}
}
/// What a floor cell belongs to, for door-threshold detection.
///
/// Tracked per cell because a corridor may be carved *over* a room interior, so
/// a single cell can be both — and a door's two sides must be genuinely
/// different (a clean room floor facing corridor floor), not the same opened-up
/// run. Stored in a [`BTreeMap`] so the scan order that picks each edge's
/// representative door is deterministic.
#[derive(Clone, Copy, Debug, Default)]
struct Membership {
/// Some [`Room`](RegionKind::Room) region claims this cell. Carries the
/// owning room's id, used to attribute a door to an edge endpoint.
room: Option<RegionId>,
/// Some [`Corridor`](RegionKind::Corridor) region claims this cell.
corridor: bool,
}
impl Pass for DoorPlacer {
fn name(&self) -> &str {
"door_placer"
}
fn apply(&self, ctx: &mut GenContext, _rng: &mut Rng) {
// Build the per-cell membership lookup from the regions. Rooms are
// recorded first so a cell that a corridor later carves over still
// remembers its owning room id; `corridor` is OR-ed in independently.
let mut membership: BTreeMap<(i32, i32), Membership> = BTreeMap::new();
for region in &ctx.regions {
match region.kind {
RegionKind::Room => {
for &c in &region.cells {
let entry = membership.entry((c.x, c.y)).or_default();
// Keep the first (lowest-id) room that claims the cell.
if entry.room.is_none() {
entry.room = Some(region.id);
}
}
}
RegionKind::Corridor => {
for &c in &region.cells {
membership.entry((c.x, c.y)).or_default().corridor = true;
}
}
}
}
// Helpers reading the membership map. A cell is a "clean room floor" when
// a room claims it and no corridor does — that is the room interior side
// of a threshold. A "corridor floor" is any cell a corridor claims.
let clean_room = |p: Point| -> Option<RegionId> {
membership.get(&(p.x, p.y)).and_then(|m| {
if m.corridor {
None
} else {
m.room
}
})
};
let is_corridor = |p: Point| membership.get(&(p.x, p.y)).is_some_and(|m| m.corridor);
// Scan every cell in row-major order. A Wall becomes a Door when one of
// its opposite orthogonal neighbor pairs straddles a room/corridor
// threshold. Collect placements first (we only read tiles here), then
// apply the writes — keeping the borrow of `ctx.tiles` read-only during
// the scan. Each placement remembers which room it touched, so edges can
// claim a representative door afterward.
let mut placements: Vec<(Point, RegionId)> = Vec::new();
for (p, &tile) in ctx.tiles.iter() {
if tile != Tile::Wall {
continue;
}
// The two opposite-neighbor axes: vertical (N/S) and horizontal
// (W/E). For each, a door needs one side a clean room floor and the
// opposite side corridor floor (in either orientation).
let axes = [
(p.offset(0, -1), p.offset(0, 1)),
(p.offset(-1, 0), p.offset(1, 0)),
];
for (a, b) in axes {
let room_then_corridor = clean_room(a).filter(|_| is_corridor(b));
let corridor_then_room = clean_room(b).filter(|_| is_corridor(a));
if let Some(room) = room_then_corridor.or(corridor_then_room) {
placements.push((p, room));
break; // One door per wall cell; don't double-count axes.
}
}
}
// Convert the chosen walls to doors. Writes route through the bounds-safe
// accessor; every `p` here came from `iter()` so it is in bounds anyway.
for &(p, _) in &placements {
ctx.tiles.set(p, Tile::Door);
}
// Record a representative door for each edge: the first placed door (in
// row-major scan order) whose room side is one of the edge's endpoints.
// Edges are visited in graph insertion order; nothing depends on hash
// iteration order.
for edge in ctx.graph.edges_mut() {
if edge.at.is_some() {
continue;
}
if let Some(&(door, _)) = placements
.iter()
.find(|&&(_, room)| room == edge.a || room == edge.b)
{
edge.at = Some(door);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blackboard::Blackboard;
use crate::geometry::{Point, Rect};
use crate::grid::Grid;
use crate::region::{ConnGraph, Region};
/// Carves a rectangle of `Floor` into `tiles` and returns its cells in
/// row-major order — the shape a `Room` interior takes after `RoomCarver`.
fn carve_rect(tiles: &mut Grid<Tile>, r: Rect) -> Vec<Point> {
let cells: Vec<Point> = r.iter().collect();
for &p in &cells {
tiles.set(p, Tile::Floor);
}
cells
}
/// Carves an explicit list of `Floor` cells (a corridor run) into `tiles` and
/// returns them — the shape a `Corridor` region takes after `CorridorCarver`.
fn carve_cells(tiles: &mut Grid<Tile>, cells: &[Point]) -> Vec<Point> {
for &p in cells {
tiles.set(p, Tile::Floor);
}
cells.to_vec()
}
/// Pushes a region of `kind` with the given bounds + cells, assigning the
/// next sequential id (id-as-index invariant).
fn push_region(ctx: &mut GenContext, kind: RegionKind, bounds: Rect, cells: Vec<Point>) {
let id = RegionId(ctx.regions.len());
ctx.regions.push(Region {
id,
kind,
bounds,
cells,
});
}
/// Runs `DoorPlacer` over `ctx`, keyed exactly as the pipeline would
/// (`"door_placer#0"` sub-stream). The pass draws no randomness, but we hand
/// it a real forked stream to mirror the real call site.
fn run(ctx: &mut GenContext, seed: u64) {
let mut rng = Rng::from_seed(seed).fork("door_placer#0");
DoorPlacer::new().apply(ctx, &mut rng);
}
/// Builds the canonical fixture: a room rectangle, a single `Wall` gap cell,
/// then a short corridor run on the far side of that gap, plus an edge.
///
/// Layout (a slice of a wider grid), with `R` room floor, `#` the wall gap,
/// `C` corridor floor, `.` untouched wall:
/// ```text
/// R R R R # C C
/// ```
/// The room occupies `x:2..6, y:3..7`; the gap wall is at `(6, 5)`; the
/// corridor runs `x:7..10` along `y = 5`. Room is region 0, corridor region 1,
/// linked by edge {0, 1} (a contrived self-meaningful edge for the test).
fn room_gap_corridor() -> (GenContext, Point) {
let mut tiles = Grid::new(16, 12, Tile::Wall);
let room_rect = Rect::new(2, 3, 4, 4); // x:2..6, y:3..7
let room_cells = carve_rect(&mut tiles, room_rect);
let gap = Point::new(6, 5); // stays Wall; this is the threshold
let corridor_pts: Vec<Point> = (7..10).map(|x| Point::new(x, 5)).collect();
let corridor_cells = carve_cells(&mut tiles, &corridor_pts);
let corridor_bounds = Rect::new(7, 5, 3, 1);
let mut ctx = GenContext {
tiles,
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
push_region(&mut ctx, RegionKind::Room, room_rect, room_cells);
push_region(&mut ctx, RegionKind::Corridor, corridor_bounds, corridor_cells);
ctx.graph.add_edge(RegionId(0), RegionId(1));
(ctx, gap)
}
/// The pass identifies itself with the exact contract name.
#[test]
fn name_is_door_placer() {
assert_eq!(DoorPlacer::new().name(), "door_placer");
}
/// The single wall cell between room floor and corridor floor becomes a Door.
#[test]
fn threshold_wall_becomes_door() {
let (mut ctx, gap) = room_gap_corridor();
assert_eq!(ctx.tiles.get(gap), Some(&Tile::Wall), "gap starts as Wall");
run(&mut ctx, 0xABCD);
assert_eq!(
ctx.tiles.get(gap),
Some(&Tile::Door),
"the room↔corridor threshold wall must become a Door"
);
}
/// No Door is placed in the middle of a room or in the middle of a corridor;
/// the only Door is the single threshold cell.
#[test]
fn no_spurious_doors_mid_room_or_corridor() {
let (mut ctx, gap) = room_gap_corridor();
run(&mut ctx, 0xABCD);
let doors: Vec<Point> = ctx
.tiles
.iter()
.filter(|&(_, &t)| t == Tile::Door)
.map(|(p, _)| p)
.collect();
assert_eq!(doors, vec![gap], "exactly one door, at the threshold");
// No interior room cell or corridor cell was turned into a door (they
// were Floor and must stay Floor).
for &p in &ctx.regions[0].cells {
assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor), "room floor untouched");
}
for &p in &ctx.regions[1].cells {
assert_eq!(
ctx.tiles.get(p),
Some(&Tile::Floor),
"corridor floor untouched"
);
}
}
/// At least one connected edge has `at == Some(_)` after running, and it
/// points at the placed door.
#[test]
fn connected_edge_records_door_location() {
let (mut ctx, gap) = room_gap_corridor();
run(&mut ctx, 0xABCD);
let edges = ctx.graph.edges();
assert!(
edges.iter().any(|e| e.at.is_some()),
"a connected edge must record a door location"
);
assert_eq!(
edges[0].at,
Some(gap),
"the edge's representative door is the threshold cell"
);
}
/// A wall with floor on only one side (a plain room exterior wall) is NOT a
/// door: there is no corridor on the opposite side.
#[test]
fn plain_room_wall_is_not_a_door() {
let mut tiles = Grid::new(10, 10, Tile::Wall);
let room_rect = Rect::new(2, 2, 4, 4);
let room_cells = carve_rect(&mut tiles, room_rect);
let mut ctx = GenContext {
tiles,
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
push_region(&mut ctx, RegionKind::Room, room_rect, room_cells);
run(&mut ctx, 7);
// No corridor anywhere, so no wall qualifies as a door.
assert!(
ctx.tiles.iter().all(|(_, &t)| t != Tile::Door),
"a room with no adjoining corridor must get no doors"
);
}
/// A wall whose opposite sides are both corridor floor (corridor passing
/// straight through a wall gap with no room) is NOT a door: a door needs a
/// room side.
#[test]
fn corridor_only_gap_is_not_a_door() {
let mut tiles = Grid::new(10, 6, Tile::Wall);
// Two corridor segments with a single wall gap between them at (4, 3).
let left: Vec<Point> = (1..4).map(|x| Point::new(x, 3)).collect();
let right: Vec<Point> = (5..8).map(|x| Point::new(x, 3)).collect();
let left_cells = carve_cells(&mut tiles, &left);
let right_cells = carve_cells(&mut tiles, &right);
let gap = Point::new(4, 3);
let mut ctx = GenContext {
tiles,
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
push_region(&mut ctx, RegionKind::Corridor, Rect::new(1, 3, 3, 1), left_cells);
push_region(&mut ctx, RegionKind::Corridor, Rect::new(5, 3, 3, 1), right_cells);
run(&mut ctx, 11);
assert_eq!(
ctx.tiles.get(gap),
Some(&Tile::Wall),
"a corridor-to-corridor wall gap is not a door (no room side)"
);
assert!(ctx.tiles.iter().all(|(_, &t)| t != Tile::Door));
}
/// Bounds-safety: a threshold sitting on the very edge of the map, with the
/// room's wall ring partly off-grid, must not panic. Here the room hugs the
/// top-left and the corridor reaches it from the right at row 0.
#[test]
fn threshold_at_map_edge_does_not_panic() {
let mut tiles = Grid::new(8, 8, Tile::Wall);
// Room floor at column 0, rows 0..3 (its left/top walls are off-grid).
let room_pts: Vec<Point> = (0..3).map(|y| Point::new(0, y)).collect();
let room_cells = carve_cells(&mut tiles, &room_pts);
// Gap wall at (1, 0); corridor floor at (2, 0).
let gap = Point::new(1, 0);
let corridor_cells = carve_cells(&mut tiles, &[Point::new(2, 0)]);
let mut ctx = GenContext {
tiles,
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
push_region(&mut ctx, RegionKind::Room, Rect::new(0, 0, 1, 3), room_cells);
push_region(&mut ctx, RegionKind::Corridor, Rect::new(2, 0, 1, 1), corridor_cells);
// Must not panic even with off-grid neighbor probes at the boundary.
run(&mut ctx, 0xDEAD);
assert_eq!(
ctx.tiles.get(gap),
Some(&Tile::Door),
"an edge-of-map threshold is still a door"
);
}
/// Determinism: the same input reproduces identical door tiles and identical
/// edge `at` values.
#[test]
fn same_input_yields_identical_doors() {
let (mut a, _) = room_gap_corridor();
let (mut b, _) = room_gap_corridor();
run(&mut a, 0x5EED);
run(&mut b, 0x5EED);
assert_eq!(a.tiles, b.tiles, "door tiles must be reproducible");
let at_a: Vec<Option<Point>> = a.graph.edges().iter().map(|e| e.at).collect();
let at_b: Vec<Option<Point>> = b.graph.edges().iter().map(|e| e.at).collect();
assert_eq!(at_a, at_b, "edge door locations must be reproducible");
}
/// A corridor carved straight over a room interior (cells in both region
/// sets) does not spawn interior doors: the overlapped cells are corridor
/// floor, so no wall between them and the room is a clean-room/corridor
/// threshold inside the room.
#[test]
fn corridor_overlapping_room_makes_no_interior_doors() {
let mut tiles = Grid::new(12, 12, Tile::Wall);
let room_rect = Rect::new(2, 2, 5, 5);
let room_cells = carve_rect(&mut tiles, room_rect);
// Corridor runs along row 4 from inside the room out to the right.
let corridor_pts: Vec<Point> = (4..10).map(|x| Point::new(x, 4)).collect();
let corridor_cells = carve_cells(&mut tiles, &corridor_pts);
let mut ctx = GenContext {
tiles,
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
push_region(&mut ctx, RegionKind::Room, room_rect, room_cells);
push_region(
&mut ctx,
RegionKind::Corridor,
Rect::new(4, 4, 6, 1),
corridor_cells,
);
run(&mut ctx, 3);
// The only legitimate door is the wall at (7, 4): room floor at (6,4) is
// overlapped by the corridor, so the clean-room side is at the room's
// right wall... actually (6,4) is both room and corridor. The threshold
// door sits where a clean room cell faces corridor floor across a wall.
// No door should appear *inside* the room rectangle.
let interior = room_rect.inflate(-1);
for (p, &t) in ctx.tiles.iter() {
if t == Tile::Door {
assert!(
!interior.contains(p),
"door at {p:?} sits inside the room interior {interior:?}"
);
}
}
}
}

View file

@ -34,3 +34,6 @@ pub use connect::{ConnectConfig, MstConnect};
pub mod corridor;
pub use corridor::CorridorCarver;
pub mod door;
pub use door::DoorPlacer;