From e0a20f7fd1199eae6d1225fdfcdcefe03e23b8de Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Thu, 28 May 2026 21:20:29 -0600 Subject: [PATCH] =?UTF-8?q?feat(core):=20generation=20engine=20=E2=80=94?= =?UTF-8?q?=20GenContext,=20Pass,=20Pipeline,=20Blackboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- reikhelm-core/src/blackboard.rs | 147 +++++++++++ reikhelm-core/src/lib.rs | 3 + reikhelm-core/src/pass.rs | 430 ++++++++++++++++++++++++++++++++ reikhelm-core/src/passes/mod.rs | 24 ++ 4 files changed, 604 insertions(+) create mode 100644 reikhelm-core/src/blackboard.rs create mode 100644 reikhelm-core/src/pass.rs create mode 100644 reikhelm-core/src/passes/mod.rs diff --git a/reikhelm-core/src/blackboard.rs b/reikhelm-core/src/blackboard.rs new file mode 100644 index 0000000..6c1ac17 --- /dev/null +++ b/reikhelm-core/src/blackboard.rs @@ -0,0 +1,147 @@ +//! A named, type-checked scratch store passed between generation passes +//! (spec §4.6). +//! +//! Passes never call each other; they communicate only through the +//! [`crate::pass::GenContext`]. The [`Blackboard`] is the side channel for data +//! that isn't a tile, region, or edge — for example a list of room centers a +//! partitioner computes and a connector later reads. +//! +//! Slots are addressed by a string **key** *and* checked against the requested +//! type at retrieval. Keying by name (rather than by bare `TypeId`) lets two +//! passes store the *same* type under different keys without colliding — e.g. +//! two `Vec` under `"a"` and `"b"`. +//! +//! Determinism note (spec §7): the backing [`std::collections::HashMap`] is fine +//! here because access is always *keyed* — we never iterate the map to produce +//! generated output, so its (unspecified) iteration order can't leak into a +//! `Map`. + +use std::any::Any; +use std::collections::HashMap; + +/// A name-keyed, type-checked store of arbitrary values shared across passes. +/// +/// Each value is boxed as a `dyn Any` so slots of different concrete types can +/// live in one map; retrieval downcasts back to the requested type and returns +/// [`None`] on a type mismatch (or a missing key). +#[derive(Default)] +pub struct Blackboard { + /// Boxed values keyed by name. `Box` erases the concrete type at + /// storage time; [`get`](Blackboard::get) / [`take`](Blackboard::take) + /// recover it by downcasting. + slots: HashMap>, +} + +impl Blackboard { + /// Creates an empty blackboard. + pub fn new() -> Self { + Blackboard::default() + } + + /// Stores `value` under `key`, replacing any existing value at that key. + /// + /// `T: 'static` because the value is type-erased into a `Box`, and + /// only `'static` types can be `Any`. Storing a different type at an + /// already-used key simply overwrites the old slot. + pub fn insert(&mut self, key: &str, value: T) { + self.slots.insert(key.to_string(), Box::new(value)); + } + + /// Returns a shared reference to the value at `key`, if it exists **and** is + /// of type `T`. + /// + /// Returns [`None`] when the key is absent *or* when the stored value is a + /// different type than `T` — a wrong-type read is never a panic. + pub fn get(&self, key: &str) -> Option<&T> { + self.slots.get(key).and_then(|boxed| boxed.downcast_ref::()) + } + + /// Removes the value at `key` and returns it, if it exists **and** is of + /// type `T`. + /// + /// If the key is present but holds a different type, the value is left in + /// place and [`None`] is returned (the wrong-type read does not consume the + /// slot). This is the move-out counterpart to [`get`](Blackboard::get), for + /// when a pass wants to take ownership of the stored value. + pub fn take(&mut self, key: &str) -> Option { + // Only remove if the type matches; otherwise re-insert untouched so a + // type mismatch is non-destructive. + match self.slots.remove(key) { + Some(boxed) => match boxed.downcast::() { + Ok(value) => Some(*value), + Err(original) => { + self.slots.insert(key.to_string(), original); + None + } + }, + None => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Two `Vec` under different keys coexist without collision, and each + /// reads back exactly what was stored. + #[test] + fn distinct_keys_do_not_collide() { + let mut bb = Blackboard::new(); + bb.insert("a", vec![1, 2, 3]); + bb.insert("b", vec![4, 5]); + + assert_eq!(bb.get::>("a"), Some(&vec![1, 2, 3])); + assert_eq!(bb.get::>("b"), Some(&vec![4, 5])); + } + + /// `get` with the wrong type returns `None` rather than panicking. + #[test] + fn get_wrong_type_is_none() { + let mut bb = Blackboard::new(); + bb.insert("n", 42_i32); + + assert_eq!(bb.get::("n"), Some(&42)); + // Same key, wrong type. + assert_eq!(bb.get::("n"), None); + } + + /// A missing key returns `None` for both `get` and `take`. + #[test] + fn missing_key_is_none() { + let mut bb = Blackboard::new(); + assert_eq!(bb.get::("absent"), None); + assert_eq!(bb.take::("absent"), None); + } + + /// `take` moves the value out and removes the slot. + #[test] + fn take_moves_value_out() { + let mut bb = Blackboard::new(); + bb.insert("v", vec![7, 8, 9]); + + assert_eq!(bb.take::>("v"), Some(vec![7, 8, 9])); + // The slot is gone after a successful take. + assert_eq!(bb.get::>("v"), None); + } + + /// `take` with the wrong type returns `None` and leaves the slot intact. + #[test] + fn take_wrong_type_preserves_slot() { + let mut bb = Blackboard::new(); + bb.insert("n", 99_i32); + + assert_eq!(bb.take::("n"), None); + // The correct-typed value is still retrievable. + assert_eq!(bb.get::("n"), Some(&99)); + } + + /// `insert` overwrites an existing slot. + #[test] + fn insert_overwrites() { + let mut bb = Blackboard::new(); + bb.insert("k", 1_i32); + bb.insert("k", 2_i32); + assert_eq!(bb.get::("k"), Some(&2)); + } +} diff --git a/reikhelm-core/src/lib.rs b/reikhelm-core/src/lib.rs index 78b372e..7855659 100644 --- a/reikhelm-core/src/lib.rs +++ b/reikhelm-core/src/lib.rs @@ -4,8 +4,11 @@ //! dependencies. Modules are added by later tasks. pub mod ascii; +pub mod blackboard; pub mod geometry; pub mod grid; pub mod map; +pub mod pass; +pub mod passes; pub mod region; pub mod rng; diff --git a/reikhelm-core/src/pass.rs b/reikhelm-core/src/pass.rs new file mode 100644 index 0000000..9498007 --- /dev/null +++ b/reikhelm-core/src/pass.rs @@ -0,0 +1,430 @@ +//! 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" + ); + } +} diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs new file mode 100644 index 0000000..336c20e --- /dev/null +++ b/reikhelm-core/src/passes/mod.rs @@ -0,0 +1,24 @@ +//! The vocabulary of generation [`crate::pass::Pass`] implementations. +//! +//! Each concrete pass is its own submodule, added by Tasks 7–11. Passes never +//! call each other; they communicate **only** through the +//! [`crate::pass::GenContext`] fields (`tiles`, `regions`, `graph`, +//! `blackboard`). This module's job is to host them and to document the +//! integration contract below. +//! +//! # Inter-Pass Data Contract (v1 dungeon) +//! +//! This is the integration contract for the v1 dungeon recipe. Each pass reads +//! certain `GenContext` state and writes others; the table is the single source +//! of truth so the five passes can be implemented and unit-tested in parallel, +//! each by constructing a `GenContext` in its required input state. +//! +//! | Pass | Reads | Writes | +//! |------|-------|--------| +//! | `BspPartition` | (empty grid) | `ctx.regions`: one placeholder `Region { kind: Room, bounds: , cells: [] }` per BSP leaf | +//! | `RoomCarver` | `ctx.regions` (placeholder Rooms) | carves `Floor` into `ctx.tiles`; shrinks each Room's `bounds` to its actual room rect and fills `cells` | +//! | `MstConnect` | `ctx.regions` (kind=Room, non-empty `cells`) centers | `ctx.graph`: edges `{ a, b, at: None }` forming an MST over rooms + a few extra loop edges | +//! | `CorridorCarver` | `ctx.graph` edges + Room region bounds/centers | carves L-shaped `Floor` corridors into `ctx.tiles`; appends `Region { kind: Corridor, .. }` per corridor | +//! | `DoorPlacer` | `ctx.tiles` + `ctx.regions` + `ctx.graph` | converts boundary `Wall` cells (room↔corridor) to `Door`; sets the corresponding `edge.at = Some(point)` | +//! +//! Submodule declarations are appended here by Tasks 7–11.