Adds RegionTheme + RoomThemer: a pure classifier (no tile changes) that labels each room (terrain-driven Forge/Cistern/Hall, special Throne/ Threshold, weighted Crypt/Den/Library/Vault/Stone). Renderer tints themed rooms; EntityPlacer biases contents by theme. Entrance/exit now chosen RNG-free from geometry so themer & placer agree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
440 lines
17 KiB
Rust
440 lines
17 KiB
Rust
//! 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::entity::Entity;
|
||
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,
|
||
theme: None,
|
||
});
|
||
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(),
|
||
});
|
||
}
|
||
}
|
||
|
||
// Harvest the entity layer the placer (if any) stashed on the blackboard
|
||
// — the side channel for data that isn't a tile/region/edge (§4.6). A
|
||
// recipe with no placer simply yields no entities.
|
||
let entities = ctx
|
||
.blackboard
|
||
.take::<Vec<Entity>>(crate::entity::BLACKBOARD_KEY)
|
||
.unwrap_or_default();
|
||
let map = Map {
|
||
tiles: ctx.tiles,
|
||
regions: ctx.regions,
|
||
graph: ctx.graph,
|
||
entities,
|
||
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"
|
||
);
|
||
}
|
||
}
|