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:
parent
8c4ef2ed4d
commit
e0a20f7fd1
4 changed files with 604 additions and 0 deletions
147
reikhelm-core/src/blackboard.rs
Normal file
147
reikhelm-core/src/blackboard.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,11 @@
|
||||||
//! dependencies. Modules are added by later tasks.
|
//! dependencies. Modules are added by later tasks.
|
||||||
|
|
||||||
pub mod ascii;
|
pub mod ascii;
|
||||||
|
pub mod blackboard;
|
||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod grid;
|
pub mod grid;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
|
pub mod pass;
|
||||||
|
pub mod passes;
|
||||||
pub mod region;
|
pub mod region;
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
|
|
|
||||||
430
reikhelm-core/src/pass.rs
Normal file
430
reikhelm-core/src/pass.rs
Normal file
|
|
@ -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
|
||||||
|
//! `"<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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
reikhelm-core/src/passes/mod.rs
Normal file
24
reikhelm-core/src/passes/mod.rs
Normal file
|
|
@ -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: <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 7–11.
|
||||||
Loading…
Reference in a new issue