feat(core): MstConnect pass
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d2d5fa1a8b
commit
d3e389d7f8
2 changed files with 525 additions and 0 deletions
522
reikhelm-core/src/passes/connect.rs
Normal file
522
reikhelm-core/src/passes/connect.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue