diff --git a/reikhelm-core/src/passes/door.rs b/reikhelm-core/src/passes/door.rs index d351153..8555bba 100644 --- a/reikhelm-core/src/passes/door.rs +++ b/reikhelm-core/src/passes/door.rs @@ -3,44 +3,82 @@ //! 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`. +//! and the connectivity [`ConnGraph`](crate::region::ConnGraph), then marks the +//! cells where a corridor pierces a room's wall — converting a chosen fraction +//! of them into [`Door`](crate::map::Tile::Door) tiles and leaving the rest as +//! open archways. +//! +//! ## Why pierce-aware, not wall-gap (the model change) +//! +//! `CorridorCarver` carves [`Floor`](crate::map::Tile::Floor) from one room +//! center straight to another room center. That run does **not** stop at the +//! room's surrounding wall — it carves right through it. So the cell just +//! outside a room rect that *would* have been a wall is now corridor `Floor`: +//! the corridor **pierces** the wall rather than meeting it at a sealed gap. +//! +//! The previous model hunted for a `Wall` cell flanked by room floor on one +//! side and corridor floor on the other. That wall no longer exists in the real +//! pipeline (a 2000-seed experiment found ~88% of room↔corridor edges got no +//! door at all), so doors were effectively never placed. +//! +//! Connectivity does **not** depend on doors: the corridor floor already joins +//! the rooms, and a `Door` and a `Floor` are both walkable. A door here is +//! therefore a purely **cosmetic threshold marker**. This pass detects the +//! pierce points and, per cell, flips a coin with probability +//! [`door_chance`](DoorConfig::door_chance) to decide door vs. open archway — +//! so a recipe can dial in any mix without affecting reachability. //! //! 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. +//! 1. Builds a per-cell membership lookup from the regions: for each `(x, y)`, +//! whether some [`Room`](crate::region::RegionKind::Room) region claims it +//! (and which room id) and whether some +//! [`Corridor`](crate::region::RegionKind::Corridor) region claims it. Stored +//! in a [`BTreeMap`] so no `HashMap` iteration order can leak into the result. +//! 2. Detects **threshold** cells. A cell `C` is a threshold iff `C` is corridor +//! floor and is *not* in any room (the pierced wall cell just outside a room), +//! and on one orthogonal axis — `(N, S)` or `(W, E)` — one neighbor is *in +//! some room* (room membership, which includes the shared room-edge cell the +//! corridor also runs through) while the **opposite** neighbor is corridor +//! floor that is *not* in any room (the corridor continuing outward). +//! 3. For each threshold, in a fixed deterministic order, draws +//! `rng.chance(door_chance)`: a hit sets `C = Door` (via the bounds-safe +//! [`Grid::set`](crate::grid::Grid::set)) and records the door + the room it +//! borders; a miss leaves `C` as `Floor` (an open archway). +//! 4. Attributes a representative door cell to each graph edge's `at` (see +//! [edge attribution](#edge-attribution) below). //! -//! 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). +//! All tile writes route through the bounds-safe +//! [`Grid::set`](crate::grid::Grid::set); all neighbor probing reads the +//! membership map (off-grid neighbors are simply absent from it), so a threshold +//! on the very edge of the map is handled without panic (spec §8). +//! +//! ## Edge attribution +//! +//! `CorridorCarver` appends exactly one `Corridor` region per graph edge, in +//! `ctx.graph.edges()` order, so the *i*-th corridor region corresponds to graph +//! edge *i*. For each edge we look at its own corridor's thresholds: if any of +//! them placed a door bordering one of the edge's two endpoint rooms +//! (`edge.a` / `edge.b`), the edge's `at` is set to that door cell; if both ends +//! became open archways (no door for that edge), `at` stays `None`. A corridor +//! that pierces a *non-endpoint* room (it passes through a third room's wall on +//! its way) may place a door there, but that door is **not** attributed to the +//! edge, because it does not border `edge.a` or `edge.b`. //! //! ## 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. +//! Thresholds are decided in a fixed order: corridor regions in append order, +//! and within each region its `cells` in stored order. A +//! [`BTreeSet`](std::collections::BTreeSet) of already-decided cells guards +//! against deciding the same cell twice when two corridors overlap it, so each +//! threshold consumes exactly one coin flip. Membership is a `BTreeMap` and +//! edges are visited in index order, so nothing depends on `HashMap`/`HashSet` +//! iteration. Given the same seed and the same carved input, the placed `Door` +//! tiles and every `edge.at` are byte-identical on every machine. The tests +//! build the tiles + regions + graph precondition by hand, depending on no +//! other pass. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use crate::geometry::Point; use crate::map::Tile; @@ -48,30 +86,58 @@ use crate::pass::{GenContext, Pass}; use crate::region::{RegionId, RegionKind}; use crate::rng::Rng; -/// A door-placing placer pass (spec §4.7). +/// Configuration for a [`DoorPlacer`] pass. /// -/// 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; +/// `door_chance` is the probability that a given room↔corridor pierce threshold +/// becomes a [`Door`](crate::map::Tile::Door) rather than an open archway +/// (plain [`Floor`](crate::map::Tile::Floor)). It is clamped to `[0.0, 1.0]` by +/// the underlying [`Rng::chance`]: `0.0` places no doors at all, `1.0` makes +/// every threshold a door. Connectivity is via the corridor floor and is +/// unaffected by this knob either way. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DoorConfig { + /// Probability in `[0.0, 1.0]` that a pierce threshold becomes a `Door` + /// instead of an open archway. + pub door_chance: f64, +} -impl DoorPlacer { - /// Creates a door placer. - pub fn new() -> Self { - DoorPlacer +impl Default for DoorConfig { + /// A sensible default for the v1 dungeon: an even mix of doors and open + /// archways at the pierce points. + fn default() -> Self { + DoorConfig { door_chance: 0.5 } } } -/// What a floor cell belongs to, for door-threshold detection. +/// A door-placing placer pass (spec §4.7). /// -/// 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. +/// Marks a configurable fraction of room↔corridor pierce thresholds as +/// [`Door`](crate::map::Tile::Door) tiles (the rest stay open +/// [`Floor`](crate::map::Tile::Floor) archways) and records a representative +/// door into each connected edge's `at`. Construct one with +/// [`DoorPlacer::new`] (or [`Default`]); it implements [`Pass`] with the stable +/// name `"door_placer"`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct DoorPlacer { + /// The door/archway mix this pass places under. + cfg: DoorConfig, +} + +impl DoorPlacer { + /// Creates a door placer with the given configuration. + pub fn new(cfg: DoorConfig) -> Self { + DoorPlacer { cfg } + } +} + +/// What a floor cell belongs to, for pierce-threshold detection. +/// +/// Tracked per cell because a corridor is carved *over* the cells it shares with +/// a room (the corridor runs from inside one room out through its wall), so a +/// single cell can be both room and corridor. A threshold is the cell that is +/// corridor floor but in **no** room — the pierced wall — with a room on one +/// side and the corridor continuing on the opposite side. Stored in a +/// [`BTreeMap`] so detection and edge attribution are deterministic. #[derive(Clone, Copy, Debug, Default)] struct Membership { /// Some [`Room`](RegionKind::Room) region claims this cell. Carries the @@ -86,7 +152,7 @@ impl Pass for DoorPlacer { "door_placer" } - fn apply(&self, ctx: &mut GenContext, _rng: &mut Rng) { + 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. @@ -110,72 +176,114 @@ impl Pass for DoorPlacer { } } - // 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 - } - }) + // Membership probes. A "pierced wall" cell is corridor floor that no + // room claims; `room_side` reports the room id of a cell some room + // claims (membership, NOT clean-room — the shared room-edge cell that + // the corridor also runs through still counts as the room side). + let is_pierced = |p: Point| { + membership + .get(&(p.x, p.y)) + .is_some_and(|m| m.corridor && m.room.is_none()) + }; + let room_side = |p: Point| -> Option { + membership.get(&(p.x, p.y)).and_then(|m| 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. + // Decide each threshold in a fixed order: corridor regions in append + // order, each region's `cells` in stored order. A door consumes one coin + // flip; an archway likewise consumes one — both branches draw exactly + // once so the stream stays in lock-step with the threshold sequence. A + // visited set guards against deciding a cell twice when two corridors + // overlap it. Each placement remembers which room it borders 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 { + let mut decided: BTreeSet<(i32, i32)> = BTreeSet::new(); + for region in &ctx.regions { + if region.kind != RegionKind::Corridor { 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. + for &c in ®ion.cells { + // Only the pierced-wall cells (corridor floor outside any room) + // can be thresholds. + if !is_pierced(c) { + continue; } + // On one axis, one neighbor is in some room and the opposite is + // the corridor continuing outward (pierced floor). Try (N, S) + // then (W, E); the room may be on either end of the axis. + let axes = [ + (c.offset(0, -1), c.offset(0, 1)), + (c.offset(-1, 0), c.offset(1, 0)), + ]; + let bordered_room = axes.iter().find_map(|&(a, b)| { + let room_a = room_side(a).filter(|_| is_pierced(b)); + let room_b = room_side(b).filter(|_| is_pierced(a)); + room_a.or(room_b) + }); + let Some(room) = bordered_room else { + continue; + }; + // A genuine threshold. Decide it at most once across corridors. + if !decided.insert((c.x, c.y)) { + continue; + } + if rng.chance(self.cfg.door_chance) { + ctx.tiles.set(c, Tile::Door); + placements.push((c, room)); + } + // A miss leaves the cell as the corridor `Floor` it already is + // (an open archway) and records no placement. } } - // 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); - } + // Attribute a representative door to each edge. CorridorCarver appends + // one Corridor region per edge in graph-edge order, so the i-th corridor + // region corresponds to edge i. We pair them by index; if the counts + // disagree (a malformed input) we attribute only what we can — no panic + // in release. + let corridor_ids: Vec = ctx + .regions + .iter() + .filter(|r| r.kind == RegionKind::Corridor) + .map(|r| r.id) + .collect(); + debug_assert_eq!( + corridor_ids.len(), + ctx.graph.edges().len(), + "expected one corridor region per graph edge" + ); - // 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() { + // For each edge, find a door placed at one of *its* corridor's + // thresholds that borders one of the edge's two endpoint rooms. A door + // this corridor placed at a non-endpoint room's wall is intentionally + // not attributed here. We borrow `ctx.regions` first (shared) and + // `ctx.graph` second (mutable) — distinct fields, so both live at once. + let regions = &ctx.regions; + for (i, edge) in ctx.graph.edges_mut().iter_mut().enumerate() { + let Some(&corridor_id) = corridor_ids.get(i) else { continue; - } - if let Some(&(door, _)) = placements - .iter() - .find(|&&(_, room)| room == edge.a || room == edge.b) - { + }; + if let Some(&(door, _)) = placements.iter().find(|&&(door, room)| { + (room == edge.a || room == edge.b) && cell_in_corridor(regions, corridor_id, door) + }) { edge.at = Some(door); } } } } +/// Returns `true` if `cell` is one of the cells of the corridor region `id`. +/// +/// Used during edge attribution to confirm a placed door belongs to *this* +/// edge's corridor (not merely some corridor that happens to border the same +/// room). Reads `cells` directly; the region list is short (one per edge plus +/// the rooms), so a linear membership check is plenty. +fn cell_in_corridor(regions: &[crate::region::Region], id: RegionId, cell: Point) -> bool { + regions + .get(id.0) + .is_some_and(|r| r.kind == RegionKind::Corridor && r.cells.contains(&cell)) +} + #[cfg(test)] mod tests { use super::*; @@ -194,8 +302,11 @@ mod tests { cells } - /// Carves an explicit list of `Floor` cells (a corridor run) into `tiles` and - /// returns them — the shape a `Corridor` region takes after `CorridorCarver`. + /// Carves an explicit list of `Floor` cells (a corridor run) into `tiles` + /// and returns them — the shape a `Corridor` region takes after + /// `CorridorCarver`. Cells inside the room are carved Floor too (already + /// Floor there), faithfully mirroring the pierce: the corridor's `cells` + /// include the in-room overlap plus the pierced run outside. fn carve_cells(tiles: &mut Grid, cells: &[Point]) -> Vec { for &p in cells { tiles.set(p, Tile::Floor); @@ -216,35 +327,37 @@ mod tests { } /// 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) { + /// (`"door_placer#0"` sub-stream). + fn run(ctx: &mut GenContext, cfg: DoorConfig, seed: u64) { let mut rng = Rng::from_seed(seed).fork("door_placer#0"); - DoorPlacer::new().apply(ctx, &mut rng); + DoorPlacer::new(cfg).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. + /// Builds the canonical PIERCE fixture, mirroring the real pipeline. /// - /// Layout (a slice of a wider grid), with `R` room floor, `#` the wall gap, - /// `C` corridor floor, `.` untouched wall: + /// A room rect is carved to Floor; a 1-wide corridor runs from inside the + /// room (sharing the room's right-edge cell), THROUGH the pierced cell just + /// outside the room, and onward. With `R` room floor, `*` the shared + /// room-edge cell (room AND corridor), `P` the pierced threshold (corridor, + /// no room), `C` corridor floor: /// ```text - /// R R R R # C C + /// R R R * P 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) { + /// The room occupies `x:2..6, y:3..7`; the shared edge cell is `(5, 5)`; the + /// pierced threshold is `(6, 5)`; the corridor continues `x:7..9` at `y = 5`. + /// Room is region 0, corridor region 1, linked by edge {0, 1}. + fn room_pierce_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 pierce = Point::new(6, 5); // corridor floor just outside the room - let corridor_pts: Vec = (7..10).map(|x| Point::new(x, 5)).collect(); + // Corridor: shared in-room cell (5,5) + pierced (6,5) + outward run. + let corridor_pts: Vec = (5..9).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 corridor_bounds = Rect::new(5, 5, 4, 1); let mut ctx = GenContext { tiles, @@ -256,36 +369,89 @@ mod tests { push_region(&mut ctx, RegionKind::Corridor, corridor_bounds, corridor_cells); ctx.graph.add_edge(RegionId(0), RegionId(1)); - (ctx, gap) + (ctx, pierce) + } + + /// 4-connected Floor-or-Door connectivity check: is `goal` reachable from + /// `start` walking only walkable cells (Floor and Door are both walkable)? + fn walkable_connected(tiles: &Grid, start: Point, goal: Point) -> bool { + let walkable = |p: Point| matches!(tiles.get(p), Some(&Tile::Floor) | Some(&Tile::Door)); + if !walkable(start) || !walkable(goal) { + return false; + } + let mut seen: BTreeSet<(i32, i32)> = BTreeSet::new(); + let mut stack = vec![start]; + seen.insert((start.x, start.y)); + while let Some(p) = stack.pop() { + if p == goal { + return true; + } + for n in tiles.neighbors4(p) { + if walkable(n) && seen.insert((n.x, n.y)) { + stack.push(n); + } + } + } + false } /// The pass identifies itself with the exact contract name. #[test] fn name_is_door_placer() { - assert_eq!(DoorPlacer::new().name(), "door_placer"); + assert_eq!(DoorPlacer::default().name(), "door_placer"); } - /// The single wall cell between room floor and corridor floor becomes a Door. + /// door_chance = 1.0: the pierced threshold cell 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"); + fn certain_door_marks_the_pierced_threshold() { + let (mut ctx, pierce) = room_pierce_corridor(); + assert_eq!( + ctx.tiles.get(pierce), + Some(&Tile::Floor), + "the pierced cell starts as corridor Floor" + ); - run(&mut ctx, 0xABCD); + run(&mut ctx, DoorConfig { door_chance: 1.0 }, 0xABCD); assert_eq!( - ctx.tiles.get(gap), + ctx.tiles.get(pierce), Some(&Tile::Door), - "the room↔corridor threshold wall must become a Door" + "with door_chance=1.0 the pierce threshold 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. + /// door_chance = 0.0: no doors at all; the threshold stays Floor and the + /// room is still walkable-connected to the corridor (connectivity is via the + /// corridor floor, independent of doors). #[test] - fn no_spurious_doors_mid_room_or_corridor() { - let (mut ctx, gap) = room_gap_corridor(); - run(&mut ctx, 0xABCD); + fn zero_chance_places_no_doors_but_stays_connected() { + let (mut ctx, pierce) = room_pierce_corridor(); + let room_center = ctx.regions[0].bounds.center(); + let corridor_far = Point::new(8, 5); + + run(&mut ctx, DoorConfig { door_chance: 0.0 }, 0xABCD); + + assert!( + ctx.tiles.iter().all(|(_, &t)| t != Tile::Door), + "door_chance=0.0 must place no doors" + ); + assert_eq!( + ctx.tiles.get(pierce), + Some(&Tile::Floor), + "the threshold stays an open Floor archway" + ); + assert!( + walkable_connected(&ctx.tiles, room_center, corridor_far), + "room must stay floor-connected to the corridor without any door" + ); + } + + /// No door appears inside a room interior or mid-corridor: with + /// door_chance=1.0 the only Door is the single pierce threshold. + #[test] + fn doors_only_at_thresholds() { + let (mut ctx, pierce) = room_pierce_corridor(); + run(&mut ctx, DoorConfig { door_chance: 1.0 }, 0xABCD); let doors: Vec = ctx .tiles @@ -293,157 +459,29 @@ mod tests { .filter(|&(_, &t)| t == Tile::Door) .map(|(p, _)| p) .collect(); - assert_eq!(doors, vec![gap], "exactly one door, at the threshold"); + assert_eq!(doors, vec![pierce], "exactly one door, at the pierce threshold"); - // No interior room cell or corridor cell was turned into a door (they - // were Floor and must stay Floor). + // Room interior cells (excluding the shared edge cell the corridor runs + // through) stay Floor — no door mid-room. for &p in &ctx.regions[0].cells { - assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor), "room floor untouched"); + assert_ne!(ctx.tiles.get(p), Some(&Tile::Door), "no door inside the room"); } + // Corridor cells other than the threshold stay Floor — no door + // mid-corridor. for &p in &ctx.regions[1].cells { - assert_eq!( - ctx.tiles.get(p), - Some(&Tile::Floor), - "corridor floor untouched" - ); + if p != pierce { + assert_ne!(ctx.tiles.get(p), Some(&Tile::Door), "no door mid-corridor"); + } } } - /// At least one connected edge has `at == Some(_)` after running, and it - /// points at the placed door. + /// A corridor that never borders a room (no room-membership neighbor) gets + /// no doors, even at door_chance=1.0. #[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(); + fn corridor_without_room_neighbor_gets_no_doors() { + let mut tiles = Grid::new(12, 6, Tile::Wall); + // A lone corridor run, no room anywhere. + let corridor_pts: Vec = (1..9).map(|x| Point::new(x, 3)).collect(); let corridor_cells = carve_cells(&mut tiles, &corridor_pts); let mut ctx = GenContext { @@ -452,29 +490,178 @@ mod tests { 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), + Rect::new(1, 3, 8, 1), corridor_cells, ); + // One edge per corridor region keeps the count well-formed (the + // endpoints name no real rooms, which is fine — no doors get placed). + ctx.graph.add_edge(RegionId(0), RegionId(0)); - run(&mut ctx, 3); + run(&mut ctx, DoorConfig { door_chance: 1.0 }, 11); - // 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:?}" - ); - } + assert!( + ctx.tiles.iter().all(|(_, &t)| t != Tile::Door), + "a corridor with no adjoining room must get no doors" + ); + } + + /// Bounds-safety: a pierce threshold sitting at the very edge of the map + /// (one neighbor off-grid) must not panic and is still detected. + #[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); + // Corridor: shared room cell (0,0), pierced (1,0), outward (2,0). + let pierce = Point::new(1, 0); + let corridor_pts = [Point::new(0, 0), pierce, Point::new(2, 0)]; + 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, Rect::new(0, 0, 1, 3), room_cells); + push_region(&mut ctx, RegionKind::Corridor, Rect::new(0, 0, 3, 1), corridor_cells); + ctx.graph.add_edge(RegionId(0), RegionId(1)); + + // Must not panic even with off-grid neighbor probes at the boundary. + run(&mut ctx, DoorConfig { door_chance: 1.0 }, 0xDEAD); + + assert_eq!( + ctx.tiles.get(pierce), + Some(&Tile::Door), + "an edge-of-map pierce threshold is still a door" + ); + } + + /// Determinism: the same seed reproduces identical Door tiles AND identical + /// edge `at` values. + #[test] + fn same_seed_yields_identical_doors_and_edges() { + let cfg = DoorConfig { door_chance: 0.5 }; + let (mut a, _) = room_pierce_corridor(); + let (mut b, _) = room_pierce_corridor(); + run(&mut a, cfg, 0x5EED); + run(&mut b, cfg, 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"); + } + + /// edge.at with door_chance=1.0 on a connected fixture: the edge's `at` is + /// Some(the door cell). + #[test] + fn edge_at_is_set_when_door_placed() { + let (mut ctx, pierce) = room_pierce_corridor(); + run(&mut ctx, DoorConfig { door_chance: 1.0 }, 0xABCD); + + let edges = ctx.graph.edges(); + assert_eq!( + edges[0].at, + Some(pierce), + "the edge's representative door is the pierce threshold cell" + ); + } + + /// edge.at with door_chance=0.0: both ends are archways, so `at` is None. + #[test] + fn edge_at_is_none_when_no_door_placed() { + let (mut ctx, _) = room_pierce_corridor(); + run(&mut ctx, DoorConfig { door_chance: 0.0 }, 0xABCD); + + let edges = ctx.graph.edges(); + assert_eq!( + edges[0].at, None, + "with no door placed the edge records no location" + ); + } + + /// Statistical: across many independent thresholds at door_chance=0.5, + /// roughly half become doors (loose bounds, mirroring rng.rs's chance test). + /// Each fixture has exactly one threshold, so one seed = one Bernoulli trial. + #[test] + fn half_of_thresholds_become_doors_at_p_half() { + let trials = 4000; + let cfg = DoorConfig { door_chance: 0.5 }; + let hits = (0..trials) + .filter(|&seed| { + let (mut ctx, pierce) = room_pierce_corridor(); + run(&mut ctx, cfg, seed); + ctx.tiles.get(pierce) == Some(&Tile::Door) + }) + .count(); + assert!( + (1600..2400).contains(&hits), + "p=0.5 over {trials} thresholds produced {hits} doors" + ); + } + + /// A corridor that pierces a NON-endpoint room places a door there, but that + /// door is not attributed to the edge (whose endpoints are different rooms). + /// Here the edge is {room0, room2}; the corridor pierces room1's wall. + #[test] + fn non_endpoint_pierce_door_is_not_attributed_to_edge() { + let mut tiles = Grid::new(20, 8, Tile::Wall); + + // Room 0 on the left, room 2 on the right, room 1 in the middle. + let r0 = Rect::new(1, 2, 3, 4); // x:1..4 + let r1 = Rect::new(8, 2, 3, 4); // x:8..11 + let r2 = Rect::new(15, 2, 3, 4); // x:15..18 + let r0_cells = carve_rect(&mut tiles, r0); + let r1_cells = carve_rect(&mut tiles, r1); + let r2_cells = carve_rect(&mut tiles, r2); + + // One corridor (for the {0,2} edge) running along y=3 from inside room 0 + // straight through room 1 and into room 2. It pierces: + // - room 0's right wall at (4,3) [borders endpoint room 0] + // - room 1's left wall at (7,3) [borders non-endpoint room 1] + // - room 1's right wall at (11,3) [borders non-endpoint room 1] + // - room 2's left wall at (14,3) [borders endpoint room 2] + let corridor_pts: Vec = (3..16).map(|x| Point::new(x, 3)).collect(); + let corridor_cells = carve_cells(&mut tiles, &corridor_pts); + let corridor_bounds = Rect::new(3, 3, 13, 1); + + let mut ctx = GenContext { + tiles, + regions: Vec::new(), + graph: ConnGraph::new(), + blackboard: Blackboard::new(), + }; + push_region(&mut ctx, RegionKind::Room, r0, r0_cells); + push_region(&mut ctx, RegionKind::Room, r1, r1_cells); + push_region(&mut ctx, RegionKind::Room, r2, r2_cells); + push_region(&mut ctx, RegionKind::Corridor, corridor_bounds, corridor_cells); + // The edge connects the two ENDPOINT rooms 0 and 2 (not the middle one). + ctx.graph.add_edge(RegionId(0), RegionId(2)); + + run(&mut ctx, DoorConfig { door_chance: 1.0 }, 7); + + // All four pierce thresholds became doors. + for &p in &[ + Point::new(4, 3), + Point::new(7, 3), + Point::new(11, 3), + Point::new(14, 3), + ] { + assert_eq!(ctx.tiles.get(p), Some(&Tile::Door), "pierce at {p:?} is a door"); } + + // But the edge.at must be a door bordering an ENDPOINT room (0 or 2), + // never one of room 1's interior pierces (7,3)/(11,3). + let at = ctx.graph.edges()[0].at.expect("edge gets a door"); + assert!( + at == Point::new(4, 3) || at == Point::new(14, 3), + "edge.at must border an endpoint room, got {at:?}" + ); } } diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index ff54610..79ddfcb 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -36,4 +36,4 @@ pub mod corridor; pub use corridor::CorridorCarver; pub mod door; -pub use door::DoorPlacer; +pub use door::{DoorConfig, DoorPlacer};