From d3e389d7f8c167387671f00fe20fd2e3772f8e52 Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Thu, 28 May 2026 21:33:16 -0600 Subject: [PATCH] feat(core): MstConnect pass Co-Authored-By: Claude Opus 4.8 (1M context) --- reikhelm-core/src/passes/connect.rs | 522 ++++++++++++++++++++++++++++ reikhelm-core/src/passes/mod.rs | 3 + 2 files changed, 525 insertions(+) create mode 100644 reikhelm-core/src/passes/connect.rs diff --git a/reikhelm-core/src/passes/connect.rs b/reikhelm-core/src/passes/connect.rs new file mode 100644 index 0000000..110cb3d --- /dev/null +++ b/reikhelm-core/src/passes/connect.rs @@ -0,0 +1,522 @@ +//! The [`MstConnect`] connector pass (spec §4.7). +//! +//! A *connector* decides **which regions should connect** — it does not carve a +//! single tile. Per the inter-pass data contract (see [`crate::passes`]), this +//! pass reads the real [`Room`](crate::region::RegionKind::Room) regions and +//! writes the connectivity [`Edge`](crate::region::Edge)s into `ctx.graph`, each +//! with `at: None` (the door cell is filled in later by `DoorPlacer`). +//! +//! A *real* room is a region with `kind == Room` **and** a non-empty `cells` +//! set. A `Room` with empty `cells` is an uncarved leaf that `RoomCarver` +//! skipped (see [`crate::passes::room`]); such placeholders are ignored here so +//! we never wire an edge to a room that does not physically exist. +//! +//! The connection plan is: +//! +//! 1. Build a **minimum spanning tree** over the room centers, with edge weight +//! equal to the [Manhattan](crate::geometry::Point::manhattan) distance +//! between centers. The MST has exactly `N - 1` edges for `N` rooms and +//! guarantees the whole dungeon is reachable. +//! 2. Add a configurable fraction +//! ([`extra_edge_ratio`](ConnectConfig::extra_edge_ratio)) of **extra short +//! edges** — the next-shortest room-to-room pairs not already in the tree — +//! so the dungeon gains loops instead of being a strict tree. +//! +//! ## Determinism (spec §7) +//! +//! Nothing here depends on `HashMap`/`HashSet` iteration order. Rooms are +//! collected in [`RegionId`](crate::region::RegionId) (index) order, the MST is +//! grown with Prim's algorithm breaking distance ties by the candidate's room +//! index, and the extra edges are chosen from a list sorted by `(weight, a, b)`. +//! Given the same `rng` sub-stream and the same room set, the emitted edge +//! sequence is byte-identical on every machine. (In v1 the plan is in fact fully +//! deterministic from the rooms alone; `rng` is accepted to honor the [`Pass`] +//! contract and leave room for future randomized loop selection.) + +use crate::pass::{GenContext, Pass}; +use crate::region::{RegionId, RegionKind}; +use crate::rng::Rng; + +/// Configuration for an [`MstConnect`] pass. +/// +/// `extra_edge_ratio` is the fraction of *extra* (non-tree) loop edges to add, +/// expressed relative to the spanning tree's edge count (`N - 1` for `N` +/// rooms). For example `0.25` over 9 rooms adds `floor(0.25 * 8) = 2` loop +/// edges on top of the 8 tree edges. `0.0` (the default) yields a pure +/// minimum spanning tree with no loops. Values are clamped to `>= 0.0` and the +/// extra count is capped at the number of available non-tree pairs, so an +/// out-of-range or huge ratio never panics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ConnectConfig { + /// Fraction of extra loop edges to add, relative to the `N - 1` tree edges. + /// `0.0` means a pure MST. + pub extra_edge_ratio: f64, +} + +impl Default for ConnectConfig { + /// A sensible default for the v1 dungeon: a pure MST (no loop edges). + fn default() -> Self { + ConnectConfig { + extra_edge_ratio: 0.0, + } + } +} + +/// A minimum-spanning-tree connector pass (spec §4.7). +/// +/// Reads the real [`Room`](crate::region::RegionKind::Room) regions, builds an +/// MST over their centers plus a configurable fraction of extra loop edges, and +/// records each link in `ctx.graph` (all with `at: None`). Carves no tiles. +/// Construct one with [`MstConnect::new`]; it implements [`Pass`] with the +/// stable name `"mst_connect"`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MstConnect { + /// The loop-density configuration this connector plans under. + cfg: ConnectConfig, +} + +impl MstConnect { + /// Creates a connector with the given configuration. + pub fn new(cfg: ConnectConfig) -> Self { + MstConnect { cfg } + } +} + +impl Pass for MstConnect { + fn name(&self) -> &str { + "mst_connect" + } + + fn apply(&self, ctx: &mut GenContext, _rng: &mut Rng) { + // Collect the *real* rooms in RegionId (index) order: kind == Room AND a + // non-empty cell set. An empty-`cells` Room is an uncarved leaf and is + // skipped, so we never connect to a room that does not exist. Index order + // is the deterministic, hash-free basis for everything below. + let rooms: Vec = ctx + .regions + .iter() + .filter(|r| r.kind == RegionKind::Room && !r.cells.is_empty()) + .map(|r| RoomNode { + id: r.id, + center: r.bounds.center(), + }) + .collect(); + + let n = rooms.len(); + // Zero or one room: nothing to connect. No edges, no panic. + if n < 2 { + return; + } + + // 1. Minimum spanning tree over room centers (Prim's algorithm). The set + // of tree edges is recorded as pairs of *room-list indices* (not + // RegionIds) so we can cheaply test membership when choosing extras. + let tree = build_mst(&rooms); + + // 2. Extra loop edges: the shortest room-to-room pairs not already in the + // tree, up to `floor(ratio * (n - 1))` of them. + let tree_count = tree.len(); // == n - 1 + let ratio = self.cfg.extra_edge_ratio.max(0.0); + let want_extra = (ratio * tree_count as f64).floor() as usize; + let extras = pick_extra_edges(&rooms, &tree, want_extra); + + // Emit tree edges first (in the order Prim's discovered them), then the + // extra loop edges (shortest first). Every endpoint is a valid Room + // RegionId; `add_edge` stores `at: None`. + for &(i, j) in tree.iter().chain(extras.iter()) { + ctx.graph.add_edge(rooms[i].id, rooms[j].id); + } + } +} + +/// A room reduced to what the connector needs: its [`RegionId`] and its center. +#[derive(Clone, Copy, Debug)] +struct RoomNode { + /// The room's region id (used as the edge endpoint). + id: RegionId, + /// The room's center cell, used to weight edges by Manhattan distance. + center: crate::geometry::Point, +} + +/// Manhattan distance between two rooms' centers (the edge weight). +fn weight(rooms: &[RoomNode], i: usize, j: usize) -> i32 { + rooms[i].center.manhattan(rooms[j].center) +} + +/// Builds a minimum spanning tree over `rooms` (length `>= 2`) with Prim's +/// algorithm, returning its `n - 1` edges as `(i, j)` room-list index pairs in +/// the order they were added. +/// +/// Determinism: the tree is grown from room index 0; at each step the +/// closest not-yet-connected room is chosen, breaking distance ties by the +/// smaller room index. No hashing or set iteration influences the result. +fn build_mst(rooms: &[RoomNode]) -> Vec<(usize, usize)> { + let n = rooms.len(); + let mut in_tree = vec![false; n]; + // For each outside room, the cheapest edge connecting it to the tree: + // `best_dist[k]` is its distance to `best_from[k]` (a room already in tree). + let mut best_dist = vec![i32::MAX; n]; + let mut best_from = vec![0usize; n]; + let mut edges = Vec::with_capacity(n - 1); + + // Seed the tree with room 0. + in_tree[0] = true; + for k in 0..n { + if k != 0 { + best_dist[k] = weight(rooms, 0, k); + best_from[k] = 0; + } + } + + // Add the remaining n - 1 rooms one at a time, each via its cheapest link. + for _ in 1..n { + // Pick the outside room with the smallest connecting distance; ties go to + // the smaller index (the `<` comparison only updates on a strict + // improvement, and we scan indices ascending). + let mut next = None; + let mut next_dist = i32::MAX; + for k in 0..n { + if !in_tree[k] && best_dist[k] < next_dist { + next_dist = best_dist[k]; + next = Some(k); + } + } + // `rooms` is connected by construction (complete graph), so `next` is + // always `Some` here; the `let-else` keeps the function total regardless. + let Some(k) = next else { break }; + + in_tree[k] = true; + edges.push((best_from[k], k)); + + // Relax the outside rooms against the newly added room `k`. + for m in 0..n { + if !in_tree[m] { + let d = weight(rooms, k, m); + if d < best_dist[m] { + best_dist[m] = d; + best_from[m] = k; + } + } + } + } + + edges +} + +/// Chooses up to `want` extra loop edges: the shortest room pairs not already in +/// `tree`, deduplicated and ordered by `(weight, a, b)` room indices. +/// +/// Returns `(i, j)` room-list index pairs with `i < j`. The deterministic sort +/// key means the same room set always yields the same loop edges. +fn pick_extra_edges( + rooms: &[RoomNode], + tree: &[(usize, usize)], + want: usize, +) -> Vec<(usize, usize)> { + if want == 0 { + return Vec::new(); + } + let n = rooms.len(); + + // Normalize tree edges to `(min, max)` so membership testing is orientation + // independent. A small sorted Vec is enough (n is tiny) and avoids any hash + // set / iteration-order dependence. + let mut tree_pairs: Vec<(usize, usize)> = tree + .iter() + .map(|&(a, b)| if a < b { (a, b) } else { (b, a) }) + .collect(); + tree_pairs.sort_unstable(); + + // Every candidate pair `i < j` that is not a tree edge, with its weight. + let mut candidates: Vec<(i32, usize, usize)> = Vec::new(); + for i in 0..n { + for j in (i + 1)..n { + if tree_pairs.binary_search(&(i, j)).is_err() { + candidates.push((weight(rooms, i, j), i, j)); + } + } + } + // Shortest first; ties broken by (a, b) index for a stable, portable order. + candidates.sort_unstable(); + + candidates + .into_iter() + .take(want) + .map(|(_, i, j)| (i, j)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::blackboard::Blackboard; + use crate::geometry::{Point, Rect}; + use crate::grid::Grid; + use crate::map::Tile; + use crate::region::{ConnGraph, Region, RegionId, RegionKind}; + + /// Builds a `GenContext` in this pass's required precondition: a set of + /// *real* Room regions (kind = Room, non-empty cells). Each room is given a + /// 2x2 footprint at the supplied top-left so its `bounds.center()` is stable. + /// Constructed by hand so the test depends on no other pass. + /// + /// The grid is all-`Wall`; this pass carves nothing, so its contents only + /// matter for the "carves no tiles" assertion. + fn ctx_with_rooms(width: u32, height: u32, room_origins: &[(i32, i32)]) -> GenContext { + let mut ctx = GenContext { + tiles: Grid::new(width, height, Tile::Wall), + regions: Vec::new(), + graph: ConnGraph::new(), + blackboard: Blackboard::new(), + }; + for &(x, y) in room_origins { + let bounds = Rect::new(x, y, 2, 2); + let cells: Vec = bounds.iter().collect(); + ctx.add_region(RegionKind::Room, bounds, cells); + } + ctx + } + + /// Runs `MstConnect` with the given config/seed over `ctx`, keyed exactly as + /// the pipeline would (`"mst_connect#0"` sub-stream). + fn run(ctx: &mut GenContext, cfg: ConnectConfig, seed: u64) { + let mut rng = Rng::from_seed(seed).fork("mst_connect#0"); + MstConnect::new(cfg).apply(ctx, &mut rng); + } + + /// Union-find over a set of edges, used to verify the graph is connected. + struct UnionFind { + parent: Vec, + } + impl UnionFind { + fn new(n: usize) -> Self { + UnionFind { + parent: (0..n).collect(), + } + } + fn find(&mut self, mut x: usize) -> usize { + while self.parent[x] != x { + self.parent[x] = self.parent[self.parent[x]]; + x = self.parent[x]; + } + x + } + fn union(&mut self, a: usize, b: usize) { + let (ra, rb) = (self.find(a), self.find(b)); + if ra != rb { + self.parent[ra] = rb; + } + } + fn components(&mut self, n: usize) -> usize { + let roots: std::collections::BTreeSet = + (0..n).map(|i| self.find(i)).collect(); + roots.len() + } + } + + /// The pass identifies itself with the exact contract name. + #[test] + fn name_is_mst_connect() { + assert_eq!( + MstConnect::new(ConnectConfig::default()).name(), + "mst_connect" + ); + } + + /// A pure MST (`extra_edge_ratio: 0.0`) over N rooms has exactly N-1 edges, + /// connects every room, and every edge has `at == None` referencing valid + /// Room region ids. + #[test] + fn pure_mst_has_n_minus_one_edges_and_is_connected() { + // 5 rooms scattered so distances differ. + let origins = [(0, 0), (20, 0), (0, 20), (20, 20), (10, 10)]; + let mut ctx = ctx_with_rooms(40, 40, &origins); + run(&mut ctx, ConnectConfig::default(), 0xABCD); + + let n = origins.len(); + let edges = ctx.graph.edges(); + assert_eq!(edges.len(), n - 1, "a pure MST over N rooms has N-1 edges"); + + // Every edge endpoint is a valid Room region id, and `at` is None. + let room_ids: std::collections::BTreeSet = ctx + .regions + .iter() + .filter(|r| r.kind == RegionKind::Room && !r.cells.is_empty()) + .map(|r| r.id) + .collect(); + for e in edges { + assert_eq!(e.at, None, "MstConnect leaves the door location unknown"); + assert!(room_ids.contains(&e.a), "edge endpoint {:?} not a room", e.a); + assert!(room_ids.contains(&e.b), "edge endpoint {:?} not a room", e.b); + assert_ne!(e.a, e.b, "no self-loops"); + } + + // The graph connects all N rooms (single component over the edges). + let mut uf = UnionFind::new(n); + for e in edges { + uf.union(e.a.0, e.b.0); + } + assert_eq!(uf.components(n), 1, "MST must connect every room"); + } + + /// A positive `extra_edge_ratio` adds the expected number of loop edges on + /// top of the N-1 tree edges, and the graph stays connected. + #[test] + fn extra_ratio_adds_expected_loop_edges() { + // 5 rooms => tree has 4 edges. ratio 0.5 => floor(0.5 * 4) = 2 extras. + let origins = [(0, 0), (20, 0), (0, 20), (20, 20), (10, 10)]; + let mut ctx = ctx_with_rooms(40, 40, &origins); + run(&mut ctx, ConnectConfig { extra_edge_ratio: 0.5 }, 0x1234); + + let n = origins.len(); + let expected = (n - 1) + 2; + assert_eq!( + ctx.graph.edges().len(), + expected, + "expected {} tree + 2 extra edges", + n - 1 + ); + + // Still connected, still all `at: None`, still room-to-room. + let mut uf = UnionFind::new(n); + for e in ctx.graph.edges() { + assert_eq!(e.at, None); + uf.union(e.a.0, e.b.0); + } + assert_eq!(uf.components(n), 1); + } + + /// `extra_edge_ratio: 0.0` produces exactly N-1 edges (no loops). + #[test] + fn zero_ratio_is_pure_mst() { + let origins = [(0, 0), (10, 0), (0, 10), (10, 10)]; + let mut ctx = ctx_with_rooms(20, 20, &origins); + run(&mut ctx, ConnectConfig { extra_edge_ratio: 0.0 }, 7); + assert_eq!(ctx.graph.edges().len(), origins.len() - 1); + } + + /// A huge ratio is capped at the number of available non-tree pairs and never + /// panics: with 4 rooms there are C(4,2)=6 possible pairs, 3 in the tree, so + /// at most 3 extras can be added regardless of the ratio. + #[test] + fn oversized_ratio_is_capped_without_panic() { + let origins = [(0, 0), (10, 0), (0, 10), (10, 10)]; + let mut ctx = ctx_with_rooms(20, 20, &origins); + run( + &mut ctx, + ConnectConfig { + extra_edge_ratio: 1000.0, + }, + 99, + ); + // 6 total pairs is the hard ceiling on edges over 4 rooms. + assert!(ctx.graph.edges().len() <= 6, "edge count cannot exceed all pairs"); + assert_eq!(ctx.graph.edges().len(), 6, "all 6 pairs added when ratio is huge"); + } + + /// A single room (N=1) produces zero edges without panic. + #[test] + fn single_room_produces_no_edges() { + let mut ctx = ctx_with_rooms(10, 10, &[(2, 2)]); + run(&mut ctx, ConnectConfig::default(), 0); + assert!(ctx.graph.edges().is_empty(), "one room has nothing to connect"); + } + + /// Zero rooms produces zero edges without panic. + #[test] + fn no_rooms_produces_no_edges() { + let mut ctx = ctx_with_rooms(10, 10, &[]); + run(&mut ctx, ConnectConfig::default(), 0); + assert!(ctx.graph.edges().is_empty()); + } + + /// Empty-`cells` Room placeholders (uncarved leaves) and non-Room regions are + /// ignored: only real rooms are connected, and edges reference only real-room + /// ids. + #[test] + fn ignores_uncarved_leaves_and_non_rooms() { + let mut ctx = GenContext { + tiles: Grid::new(40, 40, Tile::Wall), + regions: Vec::new(), + graph: ConnGraph::new(), + blackboard: Blackboard::new(), + }; + // Two real rooms (ids 0 and 2) bracketing an uncarved leaf (id 1) and a + // corridor (id 3), so the surviving ids are non-contiguous. + let r0 = Rect::new(0, 0, 2, 2); + ctx.add_region(RegionKind::Room, r0, r0.iter().collect()); // id 0: real + ctx.add_region(RegionKind::Room, Rect::new(10, 0, 4, 4), Vec::new()); // id 1: uncarved leaf + let r2 = Rect::new(20, 20, 2, 2); + ctx.add_region(RegionKind::Room, r2, r2.iter().collect()); // id 2: real + ctx.regions.push(Region { + id: RegionId(3), + kind: RegionKind::Corridor, + bounds: Rect::new(30, 0, 4, 1), + cells: vec![Point::new(30, 0)], + }); + + run(&mut ctx, ConnectConfig::default(), 0xBEEF); + + // Two real rooms => exactly one tree edge, joining ids 0 and 2. + let edges = ctx.graph.edges(); + assert_eq!(edges.len(), 1, "two real rooms => one edge"); + let e = edges[0]; + let ends = [e.a, e.b]; + assert!(ends.contains(&RegionId(0)) && ends.contains(&RegionId(2))); + assert!( + !ends.contains(&RegionId(1)) && !ends.contains(&RegionId(3)), + "must not connect an uncarved leaf or a corridor" + ); + } + + /// Carves no tiles: the grid is untouched (all Wall) after running. + #[test] + fn carves_no_tiles() { + let origins = [(0, 0), (20, 0), (0, 20), (20, 20)]; + let mut ctx = ctx_with_rooms(40, 40, &origins); + run(&mut ctx, ConnectConfig { extra_edge_ratio: 0.5 }, 5); + assert!( + ctx.tiles.iter().all(|(_, &t)| t == Tile::Wall), + "a connector must not carve any tile" + ); + } + + /// Determinism: the same seed/sub-stream and room set produce an identical + /// edge sequence (endpoints in the same order). + #[test] + fn same_seed_yields_identical_edges() { + let origins = [(0, 0), (15, 3), (4, 18), (22, 19), (9, 9), (30, 5)]; + let cfg = ConnectConfig { extra_edge_ratio: 0.5 }; + + let mut a = ctx_with_rooms(40, 40, &origins); + let mut b = ctx_with_rooms(40, 40, &origins); + run(&mut a, cfg, 0x5EED); + run(&mut b, cfg, 0x5EED); + + let edges_a: Vec<(RegionId, RegionId)> = + a.graph.edges().iter().map(|e| (e.a, e.b)).collect(); + let edges_b: Vec<(RegionId, RegionId)> = + b.graph.edges().iter().map(|e| (e.a, e.b)).collect(); + assert_eq!(edges_a, edges_b, "same seed must reproduce the same edge set"); + } + + /// Determinism is independent of `rng` in v1: the plan is derived purely from + /// the rooms, so two different seeds yield the same edges. (This documents the + /// current behavior; it also proves no hidden hash-iteration influence.) + #[test] + fn plan_is_rng_independent_in_v1() { + let origins = [(0, 0), (15, 3), (4, 18), (22, 19), (9, 9)]; + let cfg = ConnectConfig { extra_edge_ratio: 0.25 }; + + let mut a = ctx_with_rooms(40, 40, &origins); + let mut b = ctx_with_rooms(40, 40, &origins); + run(&mut a, cfg, 1); + run(&mut b, cfg, 999_999); + + let edges_a: Vec<(RegionId, RegionId)> = + a.graph.edges().iter().map(|e| (e.a, e.b)).collect(); + let edges_b: Vec<(RegionId, RegionId)> = + b.graph.edges().iter().map(|e| (e.a, e.b)).collect(); + assert_eq!(edges_a, edges_b); + } +} diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index b3b0414..df96638 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -28,3 +28,6 @@ pub use bsp::{BspConfig, BspPartition}; pub mod room; pub use room::{RoomCarver, RoomConfig}; + +pub mod connect; +pub use connect::{ConnectConfig, MstConnect};