fix(core): pierce-aware DoorPlacer with configurable door_chance

Corridors carve center-to-center and pierce room walls, so the old
wall-threshold rule placed almost no doors. Detect the pierced-wall
threshold (corridor floor outside a room, room on one side, corridor on
the opposite) and mark it Door with probability door_chance (default
0.5), else leave it an open archway. Connectivity is via corridor floor,
independent of doors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-28 22:19:59 -06:00
parent 1d003ec5fc
commit 93c32a652f
2 changed files with 477 additions and 290 deletions

View file

@ -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<RegionId> {
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<RegionId> {
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 &region.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<RegionId> = 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<Tile>, cells: &[Point]) -> Vec<Point> {
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<Point> = (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<Point> = (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<Tile>, 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<Point> = 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<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();
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<Point> = (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<Point> = (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<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");
}
/// 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<Point> = (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:?}"
);
}
}

View file

@ -36,4 +36,4 @@ pub mod corridor;
pub use corridor::CorridorCarver;
pub mod door;
pub use door::DoorPlacer;
pub use door::{DoorConfig, DoorPlacer};