feat(core): generation engine — GenContext, Pass, Pipeline, Blackboard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-28 21:20:29 -06:00
parent 8c4ef2ed4d
commit e0a20f7fd1
4 changed files with 604 additions and 0 deletions

View file

@ -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<i32>` 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<dyn Any>` erases the concrete type at
/// storage time; [`get`](Blackboard::get) / [`take`](Blackboard::take)
/// recover it by downcasting.
slots: HashMap<String, Box<dyn Any>>,
}
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<dyn Any>`, and
/// only `'static` types can be `Any`. Storing a different type at an
/// already-used key simply overwrites the old slot.
pub fn insert<T: 'static>(&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<T: 'static>(&self, key: &str) -> Option<&T> {
self.slots.get(key).and_then(|boxed| boxed.downcast_ref::<T>())
}
/// 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<T: 'static>(&mut self, key: &str) -> Option<T> {
// 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::<T>() {
Ok(value) => Some(*value),
Err(original) => {
self.slots.insert(key.to_string(), original);
None
}
},
None => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Two `Vec<i32>` 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::<Vec<i32>>("a"), Some(&vec![1, 2, 3]));
assert_eq!(bb.get::<Vec<i32>>("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::<i32>("n"), Some(&42));
// Same key, wrong type.
assert_eq!(bb.get::<String>("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::<i32>("absent"), None);
assert_eq!(bb.take::<i32>("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::<Vec<i32>>("v"), Some(vec![7, 8, 9]));
// The slot is gone after a successful take.
assert_eq!(bb.get::<Vec<i32>>("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::<String>("n"), None);
// The correct-typed value is still retrievable.
assert_eq!(bb.get::<i32>("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::<i32>("k"), Some(&2));
}
}

View file

@ -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;

430
reikhelm-core/src/pass.rs Normal file
View file

@ -0,0 +1,430 @@
//! The generation engine: [`GenContext`], [`Pass`], [`Pipeline`], [`Snapshot`]
//! (spec §4.6).
//!
//! This is the keystone contract every concrete pass (Tasks 711) 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
//! `"<pass name>#<occurrence>"`, 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<Tile>,
/// The semantic regions discovered/created so far, indexed by [`RegionId`].
pub regions: Vec<Region>,
/// 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<Point>) -> 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 (`"<name>#<occurrence>"`), 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<Tile>,
/// The regions as they stood just after the pass ran.
pub regions: Vec<Region>,
/// The connectivity edges as they stood just after the pass ran.
pub edges: Vec<Edge>,
}
/// 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<Box<dyn Pass>>,
}
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<RefCell<_>>` 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<Snapshot>) {
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<Snapshot>) {
// 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<RefCell<Vec<i32>>>,
}
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"
);
}
}

View file

@ -0,0 +1,24 @@
//! The vocabulary of generation [`crate::pass::Pass`] implementations.
//!
//! Each concrete pass is its own submodule, added by Tasks 711. 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: <leaf rect>, 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 711.