From 61aedfa0c720a5d23e89d1ee706e6d848f76038e Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Thu, 28 May 2026 21:39:02 -0600 Subject: [PATCH] feat(core): DoorPlacer pass Co-Authored-By: Claude Opus 4.8 (1M context) --- reikhelm-core/src/passes/door.rs | 480 +++++++++++++++++++++++++++++++ reikhelm-core/src/passes/mod.rs | 3 + 2 files changed, 483 insertions(+) create mode 100644 reikhelm-core/src/passes/door.rs diff --git a/reikhelm-core/src/passes/door.rs b/reikhelm-core/src/passes/door.rs new file mode 100644 index 0000000..d351153 --- /dev/null +++ b/reikhelm-core/src/passes/door.rs @@ -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, + /// 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 ®ion.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 ®ion.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 { + 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, r: Rect) -> Vec { + let cells: Vec = 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, cells: &[Point]) -> Vec { + 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) { + 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 = (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 = 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 = (1..4).map(|x| Point::new(x, 3)).collect(); + let right: Vec = (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 = (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> = a.graph.edges().iter().map(|e| e.at).collect(); + let at_b: Vec> = 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 = (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:?}" + ); + } + } + } +} diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index 7d36d19..ff54610 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -34,3 +34,6 @@ pub use connect::{ConnectConfig, MstConnect}; pub mod corridor; pub use corridor::CorridorCarver; + +pub mod door; +pub use door::DoorPlacer;