//! The generation engine: [`GenContext`], [`Pass`], [`Pipeline`], [`Snapshot`] //! (spec §4.6). //! //! This is the keystone contract every concrete pass (Tasks 7–11) consumes. The //! shape of the data flow is: //! //! - A [`Pipeline`] is a recipe: a canvas size plus an ordered list of passes. //! - [`Pipeline::run`] builds a fresh [`GenContext`] (an all-[`Tile::Wall`] grid //! and empty regions/graph/blackboard), then applies each [`Pass`] in order, //! mutating the shared context. //! - The final context is packaged into a [`crate::map::Map`] — the library's //! sole output. //! //! ## RNG isolation is owned here, not by passes (spec §4.6, §7) //! //! [`Pipeline::run`] creates one root [`Rng`] from the seed and, for each pass, //! derives a **dedicated** child stream via [`Rng::fork`] keyed by //! `"#"`, where `occurrence` is a *per-name* counter the //! run loop maintains. Each pass receives only its own `&mut Rng` and never sees //! a shared one. //! //! Because [`Rng::fork`] depends only on `(seed, key)` — not on draw history — //! this makes determinism-under-pipeline-edits **structural**: in pipelines //! `[A, B]` and `[A, X, B]`, both `A` and `B` still key to `A#0` / `B#0`, so they //! receive identical streams regardless of the inserted `X`. Inserting or //! reordering a pass never reshuffles another pass's randomness. //! //! ## `run` and `run_with_snapshots` cannot diverge (spec §7) //! //! Both public entry points delegate to the single private //! [`Pipeline::run_inner`], so the generation path is literally shared. The only //! difference is whether a [`Snapshot`] is cloned out after each pass — snapshot //! capture only *reads* context state and never touches the RNG, so the //! resulting [`Map`] is identical for a given seed with or without snapshots. use crate::blackboard::Blackboard; use crate::geometry::{Point, Rect}; use crate::grid::Grid; use crate::map::{Map, Tile}; use crate::region::{ConnGraph, Edge, Region, RegionId, RegionKind}; use crate::rng::Rng; use std::collections::HashMap; /// The mutable working state threaded through every [`Pass`] (spec §4.6). /// /// A pass reads and writes these fields directly — they are the only channel /// through which passes communicate (passes never call each other). The starting /// state for a run is an all-[`Tile::Wall`] grid with empty regions, graph, and /// blackboard; each pass carves tiles, appends regions, adds edges, or stashes /// scratch data on the [`Blackboard`]. pub struct GenContext { /// The terrain layer being carved. Starts entirely [`Tile::Wall`]. pub tiles: Grid, /// The semantic regions discovered/created so far, indexed by [`RegionId`]. pub regions: Vec, /// The region connectivity graph (edges added by connector passes). pub graph: ConnGraph, /// Named typed scratch slots for cross-pass data that isn't tiles/regions. pub blackboard: Blackboard, } impl GenContext { /// Appends a new region and returns its [`RegionId`]. /// /// The id is assigned as `RegionId(self.regions.len())` *before* the push, so /// it equals the region's index in [`regions`](GenContext::regions). Regions /// are append-only — never removed or reordered — which preserves the /// id-as-index invariant (Task 5 / [`RegionId`]) that edges and lookups rely /// on. pub fn add_region(&mut self, kind: RegionKind, bounds: Rect, cells: Vec) -> RegionId { let id = RegionId(self.regions.len()); self.regions.push(Region { id, kind, bounds, cells, }); id } } /// A single generation step (spec §4.6). /// /// Implementors are small, focused transforms (partition, carve, connect, place) /// that mutate the shared [`GenContext`]. The contract is deliberately tight: /// /// - [`name`](Pass::name) is the pass's stable identity. The pipeline uses it to /// key this pass's dedicated RNG sub-stream (`"#"`), so two /// distinct pass *kinds* should return distinct names. /// - [`apply`](Pass::apply) takes `&self`: a pass's configuration is immutable /// during a run. The pipeline — not the pass — tracks how many times a given /// name has occurred, so the same config can appear twice and still get /// distinct, stable streams. A pass draws all its randomness from the `rng` /// it is handed and from no other source. pub trait Pass { /// This pass's stable name, used to key its dedicated RNG sub-stream. fn name(&self) -> &str; /// Applies this pass to `ctx`, drawing any randomness from `rng`. /// /// `rng` is a sub-stream dedicated to this pass occurrence; a pass must not /// reach for any other source of randomness if the run is to stay /// deterministic. fn apply(&self, ctx: &mut GenContext, rng: &mut Rng); } /// A point-in-time capture of generation state, taken *after* a pass runs /// (spec §4.6). /// /// Snapshots feed a visualization scrubber that steps through generation. Each /// captures the tiles **and** the region bounds + graph edges, so passes that /// don't change tiles (partition, connect) are still visible as state changes. /// Snapshotting only clones context state — it never advances the RNG — so /// collecting snapshots cannot alter the final [`Map`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Snapshot { /// The name of the pass that produced this state (its [`Pass::name`]). pub label: String, /// The tile grid as it stood just after the pass ran. pub tiles: Grid, /// The regions as they stood just after the pass ran. pub regions: Vec, /// The connectivity edges as they stood just after the pass ran. pub edges: Vec, } /// A generation recipe: a canvas size plus an ordered list of passes. /// /// Build one with [`Pipeline::new`] and chain passes with the /// [`then`](Pipeline::then) builder, then produce a map with /// [`run`](Pipeline::run) (or [`run_with_snapshots`](Pipeline::run_with_snapshots) /// for the viz scrubber). The pipeline owns the canvas dimensions: `run` builds /// the initial all-[`Tile::Wall`] grid at that size. pub struct Pipeline { /// Canvas width in cells. width: u32, /// Canvas height in cells. height: u32, /// The passes to apply, in order. passes: Vec>, } impl Pipeline { /// Creates an empty pipeline for a `width` × `height` canvas. /// /// Add passes with [`then`](Pipeline::then). pub fn new(width: u32, height: u32) -> Self { Pipeline { width, height, passes: Vec::new(), } } /// Appends `pass` and returns the pipeline, for fluent chaining. /// /// `'static` is required because the pass is boxed and stored; any owned /// pass value (including one capturing `Rc>` for test /// observation) satisfies it. The pipeline is single-threaded, so no `Send` /// bound is needed. pub fn then(mut self, pass: impl Pass + 'static) -> Self { self.passes.push(Box::new(pass)); self } /// Runs the pipeline for `seed`, returning the generated [`Map`]. /// /// Builds a fresh [`GenContext`], applies each pass in order with its own /// forked RNG sub-stream, and packages the result. Equivalent to /// [`run_with_snapshots`](Pipeline::run_with_snapshots) minus the snapshots, /// and guaranteed to produce an identical map (both share /// [`run_inner`](Pipeline::run_inner)). pub fn run(&self, seed: u64) -> Map { let (map, _snapshots) = self.run_inner(seed, false); map } /// Runs the pipeline for `seed`, returning the [`Map`] **and** a [`Snapshot`] /// captured after each pass (in pass order). /// /// The returned map is byte-identical to [`run`](Pipeline::run) for the same /// seed — snapshot capture only reads context state and never touches the /// RNG. The snapshot count equals the number of passes. pub fn run_with_snapshots(&self, seed: u64) -> (Map, Vec) { self.run_inner(seed, true) } /// The single shared generation path behind both public entry points. /// /// When `collect` is `true`, a [`Snapshot`] is cloned out after each pass; /// otherwise the returned snapshot vec is empty. Crucially, the RNG forking /// and pass application are identical in both modes, so the resulting [`Map`] /// cannot diverge between `run` and `run_with_snapshots` (spec §7). fn run_inner(&self, seed: u64, collect: bool) -> (Map, Vec) { // Fresh context: solid-wall canvas, nothing else. let mut ctx = GenContext { tiles: Grid::new(self.width, self.height, Tile::Wall), regions: Vec::new(), graph: ConnGraph::new(), blackboard: Blackboard::new(), }; // The root stream all per-pass sub-streams fork from. let root = Rng::from_seed(seed); // Per-NAME occurrence counter: two passes sharing a name get `name#0`, // `name#1`, … — distinct, stable streams. A *different* name keeps its // own count, so inserting a pass never shifts another's occurrence. let mut occurrences: HashMap<&str, u32> = HashMap::new(); let mut snapshots = Vec::new(); for pass in &self.passes { let name = pass.name(); // Read-then-increment this name's occurrence counter. let occurrence = occurrences.entry(name).or_insert(0); let key = format!("{}#{}", name, *occurrence); *occurrence += 1; // Hand the pass *only* its own dedicated sub-stream. let mut rng = root.fork(&key); pass.apply(&mut ctx, &mut rng); if collect { // Read-only capture of post-pass state. Does not touch `rng`. snapshots.push(Snapshot { label: name.to_string(), tiles: ctx.tiles.clone(), regions: ctx.regions.clone(), edges: ctx.graph.edges().to_vec(), }); } } let map = Map { tiles: ctx.tiles, regions: ctx.regions, graph: ctx.graph, seed, width: self.width, height: self.height, }; (map, snapshots) } } #[cfg(test)] mod tests { use super::*; use std::cell::RefCell; use std::rc::Rc; /// A trivial pass that fills the entire grid with [`Tile::Floor`]. Draws no /// randomness. struct FillFloor; impl Pass for FillFloor { fn name(&self) -> &str { "FillFloor" } fn apply(&self, ctx: &mut GenContext, _rng: &mut Rng) { let (w, h) = (ctx.tiles.width() as i32, ctx.tiles.height() as i32); for y in 0..h { for x in 0..w { ctx.tiles.set(Point::new(x, y), Tile::Floor); } } } } /// A pass that records its first `range` draw into a shared cell, so a test /// can observe the sub-stream it was given despite `apply(&self)` and `run` /// returning only a `Map`. `name` is configurable so we can build same-named /// and differently-named instances. struct RecordDraw { name: String, sink: Rc>>, } impl Pass for RecordDraw { fn name(&self) -> &str { &self.name } fn apply(&self, _ctx: &mut GenContext, rng: &mut Rng) { let v = rng.range(0, 1_000_000); self.sink.borrow_mut().push(v); } } /// A no-draw pass that does not touch the RNG or the context — used to verify /// that inserting it between two passes doesn't shift their streams. struct NoOp; impl Pass for NoOp { fn name(&self) -> &str { "NoOp" } fn apply(&self, _ctx: &mut GenContext, _rng: &mut Rng) {} } /// `add_region` assigns sequential ids equal to the region's index. #[test] fn add_region_assigns_index_as_id() { let mut ctx = GenContext { tiles: Grid::new(4, 4, Tile::Wall), regions: Vec::new(), graph: ConnGraph::new(), blackboard: Blackboard::new(), }; let a = ctx.add_region(RegionKind::Room, Rect::new(0, 0, 2, 2), vec![]); let b = ctx.add_region(RegionKind::Corridor, Rect::new(2, 0, 1, 4), vec![]); assert_eq!(a, RegionId(0)); assert_eq!(b, RegionId(1)); assert_eq!(ctx.regions[0].id, RegionId(0)); assert_eq!(ctx.regions[1].id, RegionId(1)); } /// A trivial fill pass produces the expected grid and a `Map` with matching /// seed and dimensions. #[test] fn trivial_fill_floor_pass() { let map = Pipeline::new(3, 2).then(FillFloor).run(0x1234); assert_eq!(map.seed, 0x1234); assert_eq!((map.width, map.height), (3, 2)); // Every cell is Floor. assert!(map.tiles.iter().all(|(_, &t)| t == Tile::Floor)); } /// Same seed → equal maps. #[test] fn run_is_deterministic() { let build = || Pipeline::new(8, 8).then(FillFloor); let a = build().run(777); let b = build().run(777); assert_eq!(a, b); } /// `run` and `run_with_snapshots` produce equal maps; one snapshot per pass. #[test] fn snapshots_match_run_and_count_passes() { let build = || { Pipeline::new(6, 6) .then(FillFloor) .then(NoOp) .then(FillFloor) }; let plain = build().run(2024); let (snapped, snapshots) = build().run_with_snapshots(2024); assert_eq!(plain, snapped, "snapshotting must not change the map"); assert_eq!(snapshots.len(), 3, "one snapshot per pass"); assert_eq!(snapshots[0].label, "FillFloor"); assert_eq!(snapshots[1].label, "NoOp"); assert_eq!(snapshots[2].label, "FillFloor"); } /// RNG isolation: inserting a pass `X` between `A` and `B` does not change /// the streams `A` and `B` receive. We compare the first draws of `A` and /// `B` across pipelines `[A, B]` and `[A, X, B]`. #[test] fn inserting_a_pass_does_not_reshuffle_others() { let seed = 0xFEED_FACE; // Pipeline [A, B]. let a_sink1 = Rc::new(RefCell::new(Vec::new())); let b_sink1 = Rc::new(RefCell::new(Vec::new())); Pipeline::new(4, 4) .then(RecordDraw { name: "A".to_string(), sink: a_sink1.clone(), }) .then(RecordDraw { name: "B".to_string(), sink: b_sink1.clone(), }) .run(seed); // Pipeline [A, X, B] — X draws then discards (proving it's about keying, // not draw history). let a_sink2 = Rc::new(RefCell::new(Vec::new())); let b_sink2 = Rc::new(RefCell::new(Vec::new())); let x_sink = Rc::new(RefCell::new(Vec::new())); Pipeline::new(4, 4) .then(RecordDraw { name: "A".to_string(), sink: a_sink2.clone(), }) .then(RecordDraw { name: "X".to_string(), sink: x_sink.clone(), }) .then(RecordDraw { name: "B".to_string(), sink: b_sink2.clone(), }) .run(seed); assert_eq!( a_sink1.borrow().as_slice(), a_sink2.borrow().as_slice(), "A's stream must be unchanged by inserting X" ); assert_eq!( b_sink1.borrow().as_slice(), b_sink2.borrow().as_slice(), "B's stream must be unchanged by inserting X" ); } /// Occurrence keying: two instances of the same-named pass receive different /// sub-streams (`name#0` vs `name#1`), so their recorded draws differ. #[test] fn same_name_instances_get_distinct_streams() { let sink = Rc::new(RefCell::new(Vec::new())); Pipeline::new(4, 4) .then(RecordDraw { name: "Dup".to_string(), sink: sink.clone(), }) .then(RecordDraw { name: "Dup".to_string(), sink: sink.clone(), }) .run(123); let draws = sink.borrow(); assert_eq!(draws.len(), 2); assert_ne!( draws[0], draws[1], "occurrence #0 and #1 must draw from distinct streams" ); } }