feat(core): MstConnect pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-28 21:33:16 -06:00
parent d2d5fa1a8b
commit d3e389d7f8
2 changed files with 525 additions and 0 deletions

View file

@ -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<RoomNode> = 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<Point> = 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<usize>,
}
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<usize> =
(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<RegionId> = 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);
}
}

View file

@ -28,3 +28,6 @@ pub use bsp::{BspConfig, BspPartition};
pub mod room; pub mod room;
pub use room::{RoomCarver, RoomConfig}; pub use room::{RoomCarver, RoomConfig};
pub mod connect;
pub use connect::{ConnectConfig, MstConnect};