feat(core): data model + Map output contract + ASCII renderer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-28 21:10:47 -06:00
parent 6ffe0cc68c
commit 659ef00017
6 changed files with 406 additions and 0 deletions

1
Cargo.lock generated
View file

@ -407,6 +407,7 @@ dependencies = [
"rand", "rand",
"rand_chacha", "rand_chacha",
"serde", "serde",
"serde_json",
] ]
[[package]] [[package]]

View file

@ -7,3 +7,6 @@ edition = "2021"
rand = "0.10.1" rand = "0.10.1"
rand_chacha = "0.10.0" rand_chacha = "0.10.0"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
[dev-dependencies]
serde_json = "1.0.150"

106
reikhelm-core/src/ascii.rs Normal file
View file

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

View file

@ -3,6 +3,9 @@
//! This crate holds the pure generation logic with no rendering or GUI //! This crate holds the pure generation logic with no rendering or GUI
//! dependencies. Modules are added by later tasks. //! dependencies. Modules are added by later tasks.
pub mod ascii;
pub mod geometry; pub mod geometry;
pub mod grid; pub mod grid;
pub mod map;
pub mod region;
pub mod rng; pub mod rng;

118
reikhelm-core/src/map.rs Normal file
View file

@ -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<Tile>` 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<Tile>` 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<Tile>,
/// The semantic areas (e.g. "Room #3 is this rect, kind = Room").
pub regions: Vec<Region>,
/// 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<Tile>`, 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());
}
}

175
reikhelm-core/src/region.rs Normal file
View file

@ -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<Point>,
}
/// 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<Point>,
}
/// 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<Edge>,
}
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<Item = RegionId> + '_ {
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<RegionId> = 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<RegionId> = g.neighbors(RegionId(0)).collect();
assert_eq!(n0, vec![RegionId(1)]);
let n2: Vec<RegionId> = 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)));
}
}