reikhelm/reikhelm-core/src/pass.rs
Parley Hatch e0a20f7fd1 feat(core): generation engine — GenContext, Pass, Pipeline, Blackboard
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:20:29 -06:00

430 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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"
);
}
}