From 659ef000179bdebf567e49c54a4d17e3eb56dba8 Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Thu, 28 May 2026 21:10:47 -0600 Subject: [PATCH] feat(core): data model + Map output contract + ASCII renderer Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + reikhelm-core/Cargo.toml | 3 + reikhelm-core/src/ascii.rs | 106 ++++++++++++++++++++++ reikhelm-core/src/lib.rs | 3 + reikhelm-core/src/map.rs | 118 ++++++++++++++++++++++++ reikhelm-core/src/region.rs | 175 ++++++++++++++++++++++++++++++++++++ 6 files changed, 406 insertions(+) create mode 100644 reikhelm-core/src/ascii.rs create mode 100644 reikhelm-core/src/map.rs create mode 100644 reikhelm-core/src/region.rs diff --git a/Cargo.lock b/Cargo.lock index 964b555..e1bbae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,6 +407,7 @@ dependencies = [ "rand", "rand_chacha", "serde", + "serde_json", ] [[package]] diff --git a/reikhelm-core/Cargo.toml b/reikhelm-core/Cargo.toml index 6db2ae3..6b2e906 100644 --- a/reikhelm-core/Cargo.toml +++ b/reikhelm-core/Cargo.toml @@ -7,3 +7,6 @@ edition = "2021" rand = "0.10.1" rand_chacha = "0.10.0" serde = { version = "1.0.228", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0.150" diff --git a/reikhelm-core/src/ascii.rs b/reikhelm-core/src/ascii.rs new file mode 100644 index 0000000..a227540 --- /dev/null +++ b/reikhelm-core/src/ascii.rs @@ -0,0 +1,106 @@ +//! ASCII rendering for [`Map`] (spec §4.9). +//! +//! Lives in `reikhelm-core` because it has no external dependencies and is the +//! workhorse for unit tests and quick CLI checks. It is a *renderer*: it reads a +//! `Map` and turns each [`Tile`] into a glyph, holding no generation logic. +//! +//! The glyph mapping is: +//! +//! | Tile | Glyph | +//! |---------------|-------| +//! | [`Tile::Wall`] | `#` | +//! | [`Tile::Floor`] | `.` | +//! | [`Tile::Door`] | `+` | +//! +//! Output is one grid row per line, in row-major order, with no trailing +//! newline. + +use crate::geometry::Point; +use crate::map::{Map, Tile}; + +/// Returns the glyph for a tile, per the documented mapping. +const fn glyph(tile: Tile) -> char { + match tile { + Tile::Wall => '#', + Tile::Floor => '.', + Tile::Door => '+', + } +} + +impl Map { + /// Renders the tile layer to a multi-line ASCII string. + /// + /// One line per grid row, top to bottom, with cells left to right. There is + /// no trailing newline. Glyphs follow the module's mapping. Any cell that + /// somehow reads out of bounds (it cannot for a well-formed map) falls back + /// to the wall glyph, so this method never panics. + pub fn to_ascii(&self) -> String { + let width = self.tiles.width(); + let height = self.tiles.height(); + // Each row is `width` glyphs; rows are joined by '\n' (height - 1 of them). + let mut out = String::with_capacity((width as usize + 1) * height as usize); + + for y in 0..height as i32 { + if y != 0 { + out.push('\n'); + } + for x in 0..width as i32 { + let tile = self.tiles.get(Point::new(x, y)).copied().unwrap_or_default(); + out.push(glyph(tile)); + } + } + + out + } + + /// Prints the ASCII rendering to standard output, followed by a newline. + pub fn print_ascii(&self) { + println!("{}", self.to_ascii()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::grid::Grid; + use crate::region::ConnGraph; + + /// Builds a 3x3 map with a known layout exercising all three glyphs. + /// + /// Layout: + /// ```text + /// ### + /// #.+ + /// ### + /// ``` + fn hand_built_3x3() -> Map { + let mut tiles = Grid::new(3, 3, Tile::Wall); + tiles.set(Point::new(1, 1), Tile::Floor); + tiles.set(Point::new(2, 1), Tile::Door); + + Map { + tiles, + regions: Vec::new(), + graph: ConnGraph::new(), + seed: 0, + width: 3, + height: 3, + } + } + + #[test] + fn to_ascii_matches_known_layout_with_door() { + let map = hand_built_3x3(); + let expected = "###\n#.+\n###"; + assert_eq!(map.to_ascii(), expected); + } + + #[test] + fn to_ascii_has_no_trailing_newline_and_one_line_per_row() { + let map = hand_built_3x3(); + let ascii = map.to_ascii(); + assert!(!ascii.ends_with('\n')); + assert_eq!(ascii.lines().count(), 3); + assert!(ascii.lines().all(|line| line.chars().count() == 3)); + } +} diff --git a/reikhelm-core/src/lib.rs b/reikhelm-core/src/lib.rs index 4e636ef..78b372e 100644 --- a/reikhelm-core/src/lib.rs +++ b/reikhelm-core/src/lib.rs @@ -3,6 +3,9 @@ //! This crate holds the pure generation logic with no rendering or GUI //! dependencies. Modules are added by later tasks. +pub mod ascii; pub mod geometry; pub mod grid; +pub mod map; +pub mod region; pub mod rng; diff --git a/reikhelm-core/src/map.rs b/reikhelm-core/src/map.rs new file mode 100644 index 0000000..3bca459 --- /dev/null +++ b/reikhelm-core/src/map.rs @@ -0,0 +1,118 @@ +//! The core/viz output contract (spec §3) and the [`Tile`] terrain layer. +//! +//! [`Map`] is the single most important interface in the project: the core's +//! whole job is to produce one, and a renderer's whole job is to read one. A +//! `Map` describes **what is where** and carries **no rendering information** — +//! no colors, sprites, or pixels. The same `Map` can be drawn as ASCII, colored +//! rectangles, or sprites without the core changing. +//! +//! Every type here derives `serde::{Serialize, Deserialize}` (spec §3), so a +//! generated map can round-trip to JSON for tooling or cross-program use. + +use serde::{Deserialize, Serialize}; + +use crate::grid::Grid; +use crate::region::{ConnGraph, Region}; + +/// The base terrain occupying a single cell (spec §4.3). +/// +/// Starts minimal. New variants are additive (later: `Water`, `Lava`, `Road`, +/// `Rubble`, …); passes match only on the variants they care about. The +/// [`Default`] is [`Tile::Wall`], so a freshly allocated `Grid` is solid +/// rock waiting to be carved into. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Tile { + /// Solid, impassable rock. The default fill for a new map. + #[default] + Wall, + /// Open, walkable floor. + Floor, + /// A doorway between regions (walkable, but semantically a threshold). + Door, +} + +/// The generator's output: what occupies each cell, the semantic regions, and +/// how those regions connect (spec §3). +/// +/// This is the core/viz contract. Fields are `pub` so any renderer can read the +/// data directly; the type intentionally holds nothing visual. The `seed` is +/// retained for reproducibility — the same seed reproduces this exact map. +/// +/// `Map` does not derive `PartialEq`/`Eq` because its `tiles: Grid` does +/// not (the [`crate::grid::Grid`] storage type is generic and only derives the +/// comparison traits where it chooses to). Equality of two maps is established +/// via their serde representation instead — see the round-trip test. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Map { + /// The base terrain layer: what tile occupies each cell. + pub tiles: Grid, + /// The semantic areas (e.g. "Room #3 is this rect, kind = Room"). + pub regions: Vec, + /// How regions connect (each edge is a door/corridor link). + pub graph: ConnGraph, + /// The seed that produced this map, for reproducibility. + pub seed: u64, + /// Map width in cells. + pub width: u32, + /// Map height in cells. + pub height: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geometry::{Point, Rect}; + use crate::region::{RegionId, RegionKind}; + + /// Builds a tiny but structurally complete `Map` for serde exercises. + fn sample_map() -> Map { + let mut tiles = Grid::new(3, 2, Tile::Wall); + tiles.set(Point::new(1, 0), Tile::Floor); + tiles.set(Point::new(1, 1), Tile::Door); + + let region = Region { + id: RegionId(0), + kind: RegionKind::Room, + bounds: Rect::new(1, 0, 1, 2), + cells: vec![Point::new(1, 0), Point::new(1, 1)], + }; + + let mut graph = ConnGraph::new(); + graph.add_edge(RegionId(0), RegionId(0)); + graph.edges_mut()[0].at = Some(Point::new(1, 1)); + + Map { + tiles, + regions: vec![region], + graph, + seed: 0xDEAD_BEEF, + width: 3, + height: 2, + } + } + + #[test] + fn json_round_trip_is_equal() { + let original = sample_map(); + + let json = serde_json::to_string(&original).expect("Map serializes to JSON"); + let restored: Map = serde_json::from_str(&json).expect("Map deserializes from JSON"); + + // `Map` carries a `Grid`, which does not implement `Eq`, so we + // compare the maps through their (canonical) serde representation: a + // faithful round-trip must re-serialize to byte-identical JSON. + let reserialized = serde_json::to_string(&restored).expect("restored Map re-serializes"); + assert_eq!(json, reserialized); + + // Spot-check the semantic layer, which does implement `Eq`, directly. + assert_eq!(original.regions, restored.regions); + assert_eq!(original.graph, restored.graph); + assert_eq!(original.seed, restored.seed); + assert_eq!( + (original.width, original.height), + (restored.width, restored.height) + ); + // And the tiles via their ASCII projection (a stable, total view). + assert_eq!(original.to_ascii(), restored.to_ascii()); + } +} diff --git a/reikhelm-core/src/region.rs b/reikhelm-core/src/region.rs new file mode 100644 index 0000000..6b0516e --- /dev/null +++ b/reikhelm-core/src/region.rs @@ -0,0 +1,175 @@ +//! Semantic regions and connectivity (spec §4.5). +//! +//! Where [`crate::grid::Grid`] answers *what tile is at a cell*, this module +//! answers *what areas exist and how they connect*. A [`Region`] is a named +//! semantic area (a room, a corridor) with a bounding box and the exact set of +//! cells it occupies; a [`ConnGraph`] records which regions are linked and, +//! once doors are placed, where the link physically sits. +//! +//! The shared vocabulary here is deliberately map-type agnostic: a dungeon is +//! rooms joined by corridor edges, while a future town would be districts and +//! buildings joined by road edges — same types, different passes producing +//! them. +//! +//! All types derive `serde::{Serialize, Deserialize}` so they can travel inside +//! a [`crate::map::Map`] through JSON (spec §3). + +use serde::{Deserialize, Serialize}; + +use crate::geometry::{Point, Rect}; + +/// A handle to a [`Region`], equal to its index in the region list. +/// +/// **Invariant:** `RegionId(n)` is always the index `n` of that region in +/// `ctx.regions` / [`crate::map::Map::regions`]. Regions are append-only — never +/// removed or reordered — so this id-as-index relationship holds for the life of +/// the map. Edges in the [`ConnGraph`] and any lookup that turns an id back into +/// a region rely on it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct RegionId(pub usize); + +/// What kind of semantic area a [`Region`] represents. +/// +/// Starts minimal; new variants are additive (later: `District`, `Plaza`, +/// `Building`). Passes match only on the kinds they care about. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RegionKind { + /// A room: a carved, typically rectangular open area. + Room, + /// A corridor: a thin passage carved to connect rooms. + Corridor, +} + +/// A semantic area of the map: an id, a kind, a bounding box, and its cells. +/// +/// `bounds` is the axis-aligned bounding box; `cells` is the exact set of +/// occupied cells, which lets non-rectangular regions (e.g. cave blobs) be +/// represented faithfully rather than only as rectangles. Fields are `pub` +/// because passes and visualization read them directly across modules. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Region { + /// This region's stable id, equal to its index in the region list. + pub id: RegionId, + /// The semantic category of this region. + pub kind: RegionKind, + /// The axis-aligned bounding box enclosing all of `cells`. + pub bounds: Rect, + /// The exact cells this region occupies, in the order they were added. + pub cells: Vec, +} + +/// A connection between two regions in the [`ConnGraph`]. +/// +/// `a` and `b` are the linked regions. `at` is the physical location of the +/// connection (the door cell): it is [`None`] when the edge is first created by +/// `MstConnect`, and is filled in later by `DoorPlacer`. Refines spec §4.5, +/// where `at` was sketched as a bare `Point` — the door location simply is not +/// known until placement runs. Fields are `pub` because `DoorPlacer` writes `at` +/// and visualization reads all three. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Edge { + /// One endpoint region. + pub a: RegionId, + /// The other endpoint region. + pub b: RegionId, + /// Where the connection physically sits, once known (e.g. the door cell). + pub at: Option, +} + +/// The region connectivity graph: nodes are [`RegionId`]s, edges are [`Edge`]s. +/// +/// Built incrementally — `MstConnect` adds the edges that should exist (with no +/// location yet), then `DoorPlacer` populates each edge's `at`. The graph stores +/// edges in insertion order, which is deterministic and the order +/// [`ConnGraph::edges`] yields them in. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConnGraph { + edges: Vec, +} + +impl ConnGraph { + /// Creates an empty connectivity graph. + pub fn new() -> Self { + ConnGraph::default() + } + + /// Records that regions `a` and `b` are connected. + /// + /// The new edge's location is left as `at: None`; `DoorPlacer` fills it in + /// later. Edges are appended, so the order they were added is preserved. + pub fn add_edge(&mut self, a: RegionId, b: RegionId) { + self.edges.push(Edge { a, b, at: None }); + } + + /// Returns the edges in insertion order. + pub fn edges(&self) -> &[Edge] { + &self.edges + } + + /// Returns the edges mutably, so `DoorPlacer` can populate each `at`. + pub fn edges_mut(&mut self) -> &mut [Edge] { + &mut self.edges + } + + /// Iterates the regions directly connected to `id`. + /// + /// For every edge touching `id`, yields the *other* endpoint. An undirected + /// link `{a, b}` therefore appears from both sides: `neighbors(a)` yields `b` + /// and `neighbors(b)` yields `a`. Self-loops (`a == b == id`) yield `id`. The + /// iteration order follows the edges' insertion order. + pub fn neighbors(&self, id: RegionId) -> impl Iterator + '_ { + self.edges.iter().filter_map(move |e| { + if e.a == id { + Some(e.b) + } else if e.b == id { + Some(e.a) + } else { + None + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn add_edge_stores_none_location() { + let mut g = ConnGraph::new(); + g.add_edge(RegionId(0), RegionId(1)); + + assert_eq!(g.edges().len(), 1); + let e = g.edges()[0]; + assert_eq!(e.a, RegionId(0)); + assert_eq!(e.b, RegionId(1)); + assert_eq!(e.at, None, "MstConnect leaves the door location unknown"); + } + + #[test] + fn neighbors_returns_both_endpoints_counterparts() { + let mut g = ConnGraph::new(); + g.add_edge(RegionId(0), RegionId(1)); + g.add_edge(RegionId(1), RegionId(2)); + + // From region 1 we can reach both of its neighbors. + let n1: Vec = g.neighbors(RegionId(1)).collect(); + assert_eq!(n1, vec![RegionId(0), RegionId(2)]); + + // The link is undirected: each endpoint sees the other. + let n0: Vec = g.neighbors(RegionId(0)).collect(); + assert_eq!(n0, vec![RegionId(1)]); + let n2: Vec = g.neighbors(RegionId(2)).collect(); + assert_eq!(n2, vec![RegionId(1)]); + } + + #[test] + fn edges_mut_lets_door_placer_populate_location() { + let mut g = ConnGraph::new(); + g.add_edge(RegionId(0), RegionId(1)); + + // Simulate DoorPlacer writing the door cell into the edge. + g.edges_mut()[0].at = Some(Point::new(4, 7)); + assert_eq!(g.edges()[0].at, Some(Point::new(4, 7))); + } +}