feat(core): cellular-automata caverns (CaveShaper) + robust corridor anchoring
Eighth pass (after RoomCarver, before MstConnect): re-sculpts some rooms into organic caves. - CaveShaper/CaveConfig (cave_chance, min_room, fill, steps): fills the room rect with noise, runs the classic 4-5 cellular automaton `steps` times, keeps the LARGEST 4-connected floor component, and re-carves the room to that blob (cells + bounds updated; the rest of the footprint reverts to Wall). Too-small results leave the room clean. Caves stay Room regions — their organic outline reads as a cavern, so the renderer needs no change. - CorridorCarver now anchors to the room cell NEAREST bounds.center() instead of the center point itself. Output-preserving for convex/centered shapes (the center cell is already floor), but robust for irregular cave blobs whose AABB center may fall in rock — so a cavernized room is always reachable. - DungeonConfig gains `caves`; recipe inserts CaveShaper after RoomCarver. Core: 129 tests green (4 new: cave is connected floor within room, small/zero skip, determinism); connectivity holds with caves in the default recipe. clippy clean. Pipeline is now 8 passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7e8bb9187
commit
381a52c310
4 changed files with 380 additions and 13 deletions
340
reikhelm-core/src/passes/cave.rs
Normal file
340
reikhelm-core/src/passes/cave.rs
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
//! The [`CaveShaper`] shaper pass: organic cellular-automata caverns.
|
||||
//!
|
||||
//! A *shaper* that re-sculpts some already-carved rooms into natural-looking
|
||||
//! caves. It runs **after** [`RoomCarver`](crate::passes::room::RoomCarver) and
|
||||
//! **before** [`MstConnect`](crate::passes::connect::MstConnect), so connectors
|
||||
//! see the final cave footprint when they wire and carve corridors.
|
||||
//!
|
||||
//! For each qualifying room (large enough, chosen by chance) it:
|
||||
//!
|
||||
//! 1. Fills the room's bounding rect with random noise (interior cells start as
|
||||
//! wall with probability `fill`; a one-cell border starts as wall so the cave
|
||||
//! pulls inward), then runs `steps` rounds of the classic **4-5 cellular
|
||||
//! automaton**: a cell becomes wall when 5+ of its 8 neighbors are wall (cells
|
||||
//! off the local grid count as wall), which relaxes noise into blobby caverns.
|
||||
//! 2. Keeps the **largest 4-connected floor component** — so the resulting cave
|
||||
//! is a single connected blob, never scattered pockets.
|
||||
//! 3. Re-carves the room: cells in the cave become [`Floor`](crate::map::Tile::Floor),
|
||||
//! the rest of the room's old footprint reverts to [`Wall`](crate::map::Tile::Wall);
|
||||
//! the region's `cells` become the cave blob and `bounds` its bounding box.
|
||||
//!
|
||||
//! If the cave would be too small (or empty), the room is left as it was. Because
|
||||
//! the cave is one connected component and [`CorridorCarver`](crate::passes::corridor::CorridorCarver)
|
||||
//! anchors to the room cell nearest the bounds center (a cave cell), a
|
||||
//! cavernized room is always reachable — connectivity is preserved.
|
||||
//!
|
||||
//! Caves stay [`Room`](crate::region::RegionKind::Room) regions: their organic
|
||||
//! outline is what reads as a cavern, so renderers need no special case.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::geometry::{Point, Rect};
|
||||
use crate::map::Tile;
|
||||
use crate::pass::{GenContext, Pass};
|
||||
use crate::region::RegionKind;
|
||||
use crate::rng::Rng;
|
||||
|
||||
/// Configuration for a [`CaveShaper`] pass.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CaveConfig {
|
||||
/// Probability a qualifying room is turned into a cave.
|
||||
pub cave_chance: f64,
|
||||
/// Minimum room extent (smaller of width/height) to qualify. Caves need room
|
||||
/// to breathe; small rooms stay clean.
|
||||
pub min_room: i32,
|
||||
/// Initial wall probability for interior cells before smoothing (~0.45 gives
|
||||
/// good caves). Clamped to `[0.0, 1.0]`.
|
||||
pub fill: f64,
|
||||
/// Number of cellular-automaton smoothing rounds.
|
||||
pub steps: u32,
|
||||
}
|
||||
|
||||
impl Default for CaveConfig {
|
||||
/// A sensible default: about a third of larger rooms (min extent >= 7) become
|
||||
/// caves, from 45%-wall noise smoothed four times.
|
||||
fn default() -> Self {
|
||||
CaveConfig {
|
||||
cave_chance: 0.30,
|
||||
min_room: 7,
|
||||
fill: 0.45,
|
||||
steps: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Smallest cave (in cells) worth keeping; below this the room is left clean.
|
||||
const MIN_CAVE_CELLS: usize = 8;
|
||||
|
||||
/// A cellular-automata cave shaper pass.
|
||||
///
|
||||
/// Construct one with [`CaveShaper::new`]; it implements [`Pass`] with the stable
|
||||
/// name `"cave_shaper"`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CaveShaper {
|
||||
cfg: CaveConfig,
|
||||
}
|
||||
|
||||
impl CaveShaper {
|
||||
/// Creates a cave shaper with the given configuration.
|
||||
pub fn new(cfg: CaveConfig) -> Self {
|
||||
CaveShaper { cfg }
|
||||
}
|
||||
|
||||
/// Builds a cave blob (as global [`Point`]s) within `bounds`, or [`None`] if
|
||||
/// the smoothed automaton leaves nothing big enough to keep. Draws all
|
||||
/// randomness from `rng`.
|
||||
fn carve_cave(&self, bounds: Rect, rng: &mut Rng) -> Option<Vec<Point>> {
|
||||
let w = bounds.w.max(0) as usize;
|
||||
let h = bounds.h.max(0) as usize;
|
||||
if w < 3 || h < 3 {
|
||||
return None;
|
||||
}
|
||||
let idx = |x: usize, y: usize| y * w + x;
|
||||
let fill = self.cfg.fill.clamp(0.0, 1.0);
|
||||
|
||||
// Initialize: border is wall, interior is wall with probability `fill`.
|
||||
let mut wall = vec![true; w * h];
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..w - 1 {
|
||||
wall[idx(x, y)] = rng.chance(fill);
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth with the 4-5 rule. Cells off the grid count as wall.
|
||||
for _ in 0..self.cfg.steps {
|
||||
let mut next = wall.clone();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut walls = 0;
|
||||
for dy in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
let nx = x as i32 + dx;
|
||||
let ny = y as i32 + dy;
|
||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
||||
walls += 1; // out of bounds counts as wall
|
||||
} else if wall[idx(nx as usize, ny as usize)] {
|
||||
walls += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
next[idx(x, y)] = walls >= 5;
|
||||
}
|
||||
}
|
||||
wall = next;
|
||||
}
|
||||
|
||||
// Keep the largest 4-connected floor component.
|
||||
let component = largest_floor_component(&wall, w, h)?;
|
||||
if component.len() < MIN_CAVE_CELLS {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Map local cells to global points, in row-major order.
|
||||
let mut cells: Vec<Point> = component
|
||||
.into_iter()
|
||||
.map(|i| Point::new(bounds.x + (i % w) as i32, bounds.y + (i / w) as i32))
|
||||
.collect();
|
||||
cells.sort_by_key(|p| (p.y, p.x));
|
||||
Some(cells)
|
||||
}
|
||||
}
|
||||
|
||||
impl Pass for CaveShaper {
|
||||
fn name(&self) -> &str {
|
||||
"cave_shaper"
|
||||
}
|
||||
|
||||
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
|
||||
for idx in 0..ctx.regions.len() {
|
||||
let region = &ctx.regions[idx];
|
||||
if region.kind != RegionKind::Room || region.cells.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let bounds = region.bounds;
|
||||
if bounds.w.min(bounds.h) < self.cfg.min_room {
|
||||
continue;
|
||||
}
|
||||
if !rng.chance(self.cfg.cave_chance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(cave) = self.carve_cave(bounds, rng) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Re-carve: clear the room's old footprint, then carve the cave. The
|
||||
// old cells are this room's only carved area at this stage (corridors
|
||||
// run later), so clearing them touches nothing else.
|
||||
let old_cells = ctx.regions[idx].cells.clone();
|
||||
for &p in &old_cells {
|
||||
ctx.tiles.set(p, Tile::Wall);
|
||||
}
|
||||
for &p in &cave {
|
||||
ctx.tiles.set(p, Tile::Floor);
|
||||
}
|
||||
|
||||
// Tighten bounds to the cave and replace the region's cells.
|
||||
let region = &mut ctx.regions[idx];
|
||||
region.bounds = bounding_rect(&cave);
|
||||
region.cells = cave;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the largest 4-connected component of floor cells (`!wall`) in a `w`×`h`
|
||||
/// grid, returned as the set of flat indices, or [`None`] if there is no floor.
|
||||
fn largest_floor_component(wall: &[bool], w: usize, h: usize) -> Option<Vec<usize>> {
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut best: Option<Vec<usize>> = None;
|
||||
for start in 0..w * h {
|
||||
if wall[start] || seen[start] {
|
||||
continue;
|
||||
}
|
||||
// BFS this component.
|
||||
let mut comp = Vec::new();
|
||||
let mut stack = vec![start];
|
||||
seen[start] = true;
|
||||
while let Some(i) = stack.pop() {
|
||||
comp.push(i);
|
||||
let (x, y) = (i % w, i / w);
|
||||
let mut push = |nx: usize, ny: usize| {
|
||||
let n = ny * w + nx;
|
||||
if !wall[n] && !seen[n] {
|
||||
seen[n] = true;
|
||||
stack.push(n);
|
||||
}
|
||||
};
|
||||
if x + 1 < w {
|
||||
push(x + 1, y);
|
||||
}
|
||||
if x > 0 {
|
||||
push(x - 1, y);
|
||||
}
|
||||
if y + 1 < h {
|
||||
push(x, y + 1);
|
||||
}
|
||||
if y > 0 {
|
||||
push(x, y - 1);
|
||||
}
|
||||
}
|
||||
if best.as_ref().map(|b| comp.len() > b.len()).unwrap_or(true) {
|
||||
best = Some(comp);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The axis-aligned bounding box enclosing every point in `cells` (empty rect for
|
||||
/// an empty slice).
|
||||
fn bounding_rect(cells: &[Point]) -> Rect {
|
||||
let Some(&first) = cells.first() else {
|
||||
return Rect::new(0, 0, 0, 0);
|
||||
};
|
||||
let (mut min_x, mut min_y, mut max_x, mut max_y) = (first.x, first.y, first.x, first.y);
|
||||
for &p in &cells[1..] {
|
||||
min_x = min_x.min(p.x);
|
||||
min_y = min_y.min(p.y);
|
||||
max_x = max_x.max(p.x);
|
||||
max_y = max_y.max(p.y);
|
||||
}
|
||||
Rect::new(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::blackboard::Blackboard;
|
||||
use crate::grid::Grid;
|
||||
use crate::region::{ConnGraph, RegionId};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn ctx_with_room(w: u32, h: u32, room: Rect) -> GenContext {
|
||||
let mut ctx = GenContext {
|
||||
tiles: Grid::new(w, h, Tile::Wall),
|
||||
regions: Vec::new(),
|
||||
graph: ConnGraph::new(),
|
||||
blackboard: Blackboard::new(),
|
||||
};
|
||||
let cells: Vec<Point> = room.iter().collect();
|
||||
for &p in &cells {
|
||||
ctx.tiles.set(p, Tile::Floor);
|
||||
}
|
||||
ctx.add_region(RegionKind::Room, room, cells);
|
||||
ctx
|
||||
}
|
||||
|
||||
fn run(ctx: &mut GenContext, cfg: CaveConfig, seed: u64) {
|
||||
let mut rng = Rng::from_seed(seed).fork("cave_shaper#0");
|
||||
CaveShaper::new(cfg).apply(ctx, &mut rng);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_is_cave_shaper() {
|
||||
assert_eq!(CaveShaper::new(CaveConfig::default()).name(), "cave_shaper");
|
||||
}
|
||||
|
||||
/// A guaranteed cave in a large room: it is non-empty, smaller than the full
|
||||
/// rect (organic), 4-connected, every cell is Floor, every cell stays inside
|
||||
/// the original room rect, and the region's cells/bounds match the cave.
|
||||
#[test]
|
||||
fn cave_is_connected_floor_within_room() {
|
||||
let room = Rect::new(0, 0, 22, 18);
|
||||
let mut ctx = ctx_with_room(22, 18, room);
|
||||
run(&mut ctx, CaveConfig { cave_chance: 1.0, min_room: 7, fill: 0.45, steps: 4 }, 0x1234);
|
||||
|
||||
let r = &ctx.regions[0];
|
||||
assert!(!r.cells.is_empty(), "cave should carve cells");
|
||||
assert!((r.cells.len() as i32) < room.w * room.h, "a cave is not the full rect");
|
||||
|
||||
let set: BTreeSet<(i32, i32)> = r.cells.iter().map(|p| (p.x, p.y)).collect();
|
||||
for &p in &r.cells {
|
||||
assert!(room.contains(p), "cave cell {p:?} escaped the room rect");
|
||||
assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor), "cave cell {p:?} not Floor");
|
||||
assert!(r.bounds.contains(p), "cave cell {p:?} escaped bounds {:?}", r.bounds);
|
||||
}
|
||||
|
||||
// 4-connected: flood from the first cell reaches all of them.
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut stack = vec![(r.cells[0].x, r.cells[0].y)];
|
||||
while let Some((x, y)) = stack.pop() {
|
||||
if !set.contains(&(x, y)) || !seen.insert((x, y)) {
|
||||
continue;
|
||||
}
|
||||
stack.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]);
|
||||
}
|
||||
assert_eq!(seen.len(), r.cells.len(), "cave must be one 4-connected blob");
|
||||
}
|
||||
|
||||
/// A small room never becomes a cave; `cave_chance` 0.0 never caves anything.
|
||||
#[test]
|
||||
fn small_rooms_and_zero_chance_stay_clean() {
|
||||
let small = Rect::new(0, 0, 5, 5);
|
||||
let mut ctx = ctx_with_room(5, 5, small);
|
||||
let before = ctx.regions[0].cells.clone();
|
||||
run(&mut ctx, CaveConfig { cave_chance: 1.0, min_room: 7, fill: 0.45, steps: 4 }, 1);
|
||||
assert_eq!(ctx.regions[0].cells, before, "small room must stay a clean rect");
|
||||
|
||||
let big = Rect::new(0, 0, 20, 16);
|
||||
let mut ctx2 = ctx_with_room(20, 16, big);
|
||||
let before2 = ctx2.regions[0].cells.clone();
|
||||
run(&mut ctx2, CaveConfig { cave_chance: 0.0, min_room: 7, fill: 0.45, steps: 4 }, 1);
|
||||
assert_eq!(ctx2.regions[0].cells, before2, "cave_chance 0.0 changes nothing");
|
||||
}
|
||||
|
||||
/// Same seed reproduces an identical cave (tiles + region).
|
||||
#[test]
|
||||
fn caves_are_deterministic() {
|
||||
let room = Rect::new(0, 0, 20, 16);
|
||||
let cfg = CaveConfig { cave_chance: 1.0, min_room: 7, fill: 0.45, steps: 4 };
|
||||
let mut a = ctx_with_room(20, 16, room);
|
||||
let mut b = ctx_with_room(20, 16, room);
|
||||
run(&mut a, cfg, 0x5EED);
|
||||
run(&mut b, cfg, 0x5EED);
|
||||
assert_eq!(a.tiles, b.tiles);
|
||||
assert_eq!(a.regions[0].cells, b.regions[0].cells);
|
||||
assert_eq!(a.regions[0].bounds, b.regions[0].bounds);
|
||||
}
|
||||
}
|
||||
|
|
@ -121,15 +121,31 @@ impl Pass for CorridorCarver {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns the center of the `Room` region with id `index`, or [`None`] if the
|
||||
/// id is out of range or names a non-Room region.
|
||||
/// Returns a corridor anchor for the `Room` region with id `index`, or [`None`]
|
||||
/// if the id is out of range or names a non-Room region.
|
||||
///
|
||||
/// Endpoint ids come from `MstConnect`, which only ever links real rooms, so in
|
||||
/// the assembled recipe this always resolves; the guard simply keeps the pass
|
||||
/// total against a hand-built or malformed graph.
|
||||
/// The anchor is the room's carved cell **nearest its bounding-box center**. For
|
||||
/// a convex, centered room (rectangle, octagon, ellipse, plus) the center cell is
|
||||
/// itself carved, so this is exactly `bounds.center()` — i.e. output-preserving.
|
||||
/// For an irregular room whose AABB center may fall in rock (a cave blob), it
|
||||
/// snaps to the nearest actual floor cell, so the corridor always meets carved
|
||||
/// floor and the room cannot be left unreachable.
|
||||
///
|
||||
/// Endpoint ids come from `MstConnect`, which only ever links real rooms (with
|
||||
/// non-empty `cells`), so in the assembled recipe this always resolves to a real
|
||||
/// cell; the guards keep the pass total against a hand-built or malformed graph.
|
||||
fn room_center(ctx: &GenContext, index: crate::region::RegionId) -> Option<Point> {
|
||||
let region = ctx.regions.get(index.0)?;
|
||||
(region.kind == RegionKind::Room).then(|| region.bounds.center())
|
||||
if region.kind != RegionKind::Room {
|
||||
return None;
|
||||
}
|
||||
let target = region.bounds.center();
|
||||
region
|
||||
.cells
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by_key(|p| (p.x - target.x).pow(2) + (p.y - target.y).pow(2))
|
||||
.or(Some(target))
|
||||
}
|
||||
|
||||
/// Carves [`Floor`](Tile::Floor) at `p` (a bounds-safe no-op if `p` is off-grid)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ pub use bsp::{BspConfig, BspPartition};
|
|||
pub mod room;
|
||||
pub use room::{RoomCarver, RoomConfig, ShapeWeights};
|
||||
|
||||
pub mod cave;
|
||||
pub use cave::{CaveConfig, CaveShaper};
|
||||
|
||||
pub mod connect;
|
||||
pub use connect::{ConnectConfig, MstConnect};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,15 @@
|
|||
//! order:
|
||||
//!
|
||||
//! ```text
|
||||
//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer
|
||||
//! → PoolDecorator → PillarPlacer
|
||||
//! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver
|
||||
//! → DoorPlacer → PoolDecorator → PillarPlacer
|
||||
//! ```
|
||||
//!
|
||||
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
|
||||
//! Room region per leaf).
|
||||
//! 2. [`RoomCarver`] carves an actual room inside each leaf.
|
||||
//! 2. [`RoomCarver`] carves an actual room inside each leaf, after which
|
||||
//! [`CaveShaper`] re-sculpts some rooms into organic cellular-automata caves
|
||||
//! (each a single connected blob; the rest stay clean polygons).
|
||||
//! 3. [`MstConnect`] plans which rooms link up (a minimum spanning tree, plus
|
||||
//! optional loop edges).
|
||||
//! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge.
|
||||
|
|
@ -43,8 +45,9 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::pass::Pipeline;
|
||||
use crate::passes::{
|
||||
BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect,
|
||||
PillarConfig, PillarPlacer, PoolConfig, PoolDecorator, RoomCarver, RoomConfig, ShapeWeights,
|
||||
BspConfig, BspPartition, CaveConfig, CaveShaper, ConnectConfig, CorridorCarver, DoorConfig,
|
||||
DoorPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig, PoolDecorator, RoomCarver,
|
||||
RoomConfig, ShapeWeights,
|
||||
};
|
||||
|
||||
/// Configuration for the [`dungeon`] recipe.
|
||||
|
|
@ -63,6 +66,8 @@ pub struct DungeonConfig {
|
|||
pub bsp: BspConfig,
|
||||
/// How a room is carved inside each leaf.
|
||||
pub rooms: RoomConfig,
|
||||
/// How some rooms are re-sculpted into organic cellular-automata caves.
|
||||
pub caves: CaveConfig,
|
||||
/// How rooms are linked into a connectivity graph.
|
||||
pub connect: ConnectConfig,
|
||||
/// How room↔corridor pierce points are marked as doors.
|
||||
|
|
@ -101,6 +106,7 @@ impl Default for DungeonConfig {
|
|||
margin: 1,
|
||||
shapes: ShapeWeights::varied(),
|
||||
},
|
||||
caves: CaveConfig::default(),
|
||||
connect: ConnectConfig {
|
||||
extra_edge_ratio: 0.30,
|
||||
},
|
||||
|
|
@ -215,6 +221,7 @@ pub fn dungeon(cfg: DungeonConfig) -> Result<Pipeline, ConfigError> {
|
|||
let pipeline = Pipeline::new(cfg.width, cfg.height)
|
||||
.then(BspPartition::new(cfg.bsp))
|
||||
.then(RoomCarver::new(cfg.rooms))
|
||||
.then(CaveShaper::new(cfg.caves))
|
||||
.then(MstConnect::new(cfg.connect))
|
||||
.then(CorridorCarver::new())
|
||||
.then(DoorPlacer::new(cfg.doors))
|
||||
|
|
@ -233,9 +240,10 @@ mod tests {
|
|||
use std::collections::{BTreeSet, VecDeque};
|
||||
|
||||
/// The pass names in pipeline order, used to check snapshot labels.
|
||||
const PASS_NAMES: [&str; 7] = [
|
||||
const PASS_NAMES: [&str; 8] = [
|
||||
"bsp_partition",
|
||||
"room_carver",
|
||||
"cave_shaper",
|
||||
"mst_connect",
|
||||
"corridor_carver",
|
||||
"door_placer",
|
||||
|
|
@ -306,7 +314,7 @@ mod tests {
|
|||
let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7);
|
||||
|
||||
assert_eq!(plain, snapped, "snapshotting must not change the map");
|
||||
assert_eq!(snapshots.len(), 7, "one snapshot per pass (seven passes)");
|
||||
assert_eq!(snapshots.len(), 8, "one snapshot per pass (eight passes)");
|
||||
|
||||
let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect();
|
||||
assert_eq!(
|
||||
|
|
|
|||
Loading…
Reference in a new issue