reikhelm/reikhelm-core/src/passes/entity_placer.rs
Parley Hatch 0aec5e927d feat(core): room theming — RoomThemer classifier + themed renderer (10th pass)
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>
2026-05-31 17:03:53 -06:00

353 lines
15 KiB
Rust

//! The [`EntityPlacer`] pass: populate the dungeon with entities.
//!
//! A *placer* that drops [`Entity`](crate::entity::Entity)s onto open floor — the
//! final pass of the dungeon recipe, after all terrain (rooms, caves, corridors,
//! doors, pools, pillars) is settled. It changes **no** tiles or regions; it
//! produces an overlay layer the engine harvests into [`Map::entities`](crate::map::Map::entities).
//!
//! Because [`GenContext`] has no entity field (entities aren't tiles, regions, or
//! edges), this pass stashes its `Vec<Entity>` on the [`Blackboard`](crate::blackboard::Blackboard)
//! under [`crate::entity::BLACKBOARD_KEY`] — the side channel designed for exactly
//! this kind of cross-pass/-engine handoff (spec §4.6) — and
//! [`Pipeline`](crate::pass::Pipeline) takes it out into the final `Map`.
//!
//! Placement, in a fixed (deterministic) order:
//!
//! 1. **Entrance** — the way-in room chosen *purely from geometry* (no RNG) by
//! [`entrance_exit_indices`]; the entity sits on the floor cell nearest its
//! center.
//! 2. **Exit** — the room whose center is farthest (Manhattan) from the entrance,
//! again on its central floor cell. Gives the dungeon a traversal goal. (The
//! entrance/exit choice is RNG-free so a themer can reproduce it and land its
//! `Throne`/`Threshold` themes on these very rooms.)
//! 3. **Treasure** — each room has a `treasure_chance` of holding one (scaled by
//! the room's [`RegionTheme`] treasure bias), on a random open floor cell.
//! 4. **Monsters** — each room *except the entrance room* has a `monster_chance`
//! (scaled by the room's theme monster bias) of holding 1..=`max_monsters`,
//! scattered on open floor.
//!
//! Entities only ever land on plain [`Floor`](crate::map::Tile::Floor) (never a
//! door, pool, pillar, or wall), and never two on one cell, so a renderer or game
//! can treat `at` as a free walkable cell.
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use crate::entity::{Entity, EntityKind, BLACKBOARD_KEY};
use crate::geometry::Point;
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
use crate::region::{Region, RegionKind, RegionTheme};
use crate::rng::Rng;
/// Picks `(entrance_index, exit_index)` into a room list purely from geometry —
/// **no RNG** — so any pass can reproduce the same choice without coupling to
/// another pass's random stream. `centers[i]` is room `i`'s center.
///
/// - The **entrance** is the room whose center is lexicographically smallest
/// (top-most row, then left-most column): a stable, corner-ward way in.
/// - The **exit** is the room whose center is farthest (Manhattan) from the
/// entrance — the natural traversal goal.
///
/// Returns `None` for an empty list; with a single room both indices are `0`.
/// [`RoomThemer`](crate::passes::room_themer::RoomThemer) calls this with the
/// same room set to land its `Throne`/`Threshold` themes on the rooms that
/// actually receive the `Exit`/`Entrance` markers — the two passes agree by
/// construction rather than by replaying each other's randomness.
pub(crate) fn entrance_exit_indices(centers: &[Point]) -> Option<(usize, usize)> {
if centers.is_empty() {
return None;
}
let entrance = (0..centers.len())
.min_by_key(|&i| (centers[i].y, centers[i].x))
.expect("non-empty");
let from = centers[entrance];
let exit = (0..centers.len())
.max_by_key(|&i| centers[i].manhattan(from))
.unwrap_or(entrance);
Some((entrance, exit))
}
/// Configuration for an [`EntityPlacer`] pass.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct EntityConfig {
/// Probability that a given room holds a treasure.
pub treasure_chance: f64,
/// Probability that a given room (other than the entrance room) holds
/// monsters.
pub monster_chance: f64,
/// The most monsters a single monster-room may hold (each placed on its own
/// floor cell). The actual count is `1..=max_monsters`.
pub max_monsters: i32,
}
impl Default for EntityConfig {
/// A sensible default: about a third of rooms hold treasure, half hold up to
/// three monsters.
fn default() -> Self {
EntityConfig {
treasure_chance: 0.35,
monster_chance: 0.50,
max_monsters: 3,
}
}
}
/// An entity-placing pass.
///
/// Construct one with [`EntityPlacer::new`]; it implements [`Pass`] with the
/// stable name `"entity_placer"`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EntityPlacer {
cfg: EntityConfig,
}
impl EntityPlacer {
/// Creates an entity placer with the given configuration.
pub fn new(cfg: EntityConfig) -> Self {
EntityPlacer { cfg }
}
}
/// A real room's data, snapshotted so placement doesn't hold a borrow on `ctx`.
struct RoomInfo {
center: Point,
/// The room's open-floor cells (Tile::Floor only), row-major.
floor: Vec<Point>,
/// The room's theme, if a themer classified it (drives content biases).
theme: Option<RegionTheme>,
}
impl Pass for EntityPlacer {
fn name(&self) -> &str {
"entity_placer"
}
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
// Snapshot each real room's center and its open-floor cells. Only plain
// Floor counts — doors, pools, pillars and walls are never entity homes.
let rooms: Vec<RoomInfo> = ctx
.regions
.iter()
.filter(|r: &&Region| r.kind == RegionKind::Room && !r.cells.is_empty())
.map(|r| RoomInfo {
center: r.bounds.center(),
floor: r
.cells
.iter()
.copied()
.filter(|&p| ctx.tiles.get(p) == Some(&Tile::Floor))
.collect(),
theme: r.theme,
})
.collect();
let mut entities: Vec<Entity> = Vec::new();
let mut taken: BTreeSet<(i32, i32)> = BTreeSet::new();
// Entrance/exit are chosen by the shared geometry helper (no RNG), so the
// themer's Throne/Threshold land on these very rooms (see
// [`entrance_exit_indices`]).
let centers: Vec<Point> = rooms.iter().map(|r| r.center).collect();
if let Some((entrance_idx, exit_idx)) = entrance_exit_indices(&centers) {
// 1) Entrance — the way-in room, central floor cell.
if let Some(p) = nearest_free(&rooms[entrance_idx], rooms[entrance_idx].center, &taken) {
taken.insert((p.x, p.y));
entities.push(Entity::new(EntityKind::Entrance, p));
}
// 2) Exit — the far room, central floor cell.
if let Some(p) = nearest_free(&rooms[exit_idx], rooms[exit_idx].center, &taken) {
taken.insert((p.x, p.y));
entities.push(Entity::new(EntityKind::Exit, p));
}
// 3 & 4) Per-room treasure and monsters, in room order for determinism.
// Each room's theme scales the base chances (no theme = neutral 1.0),
// clamped to a valid probability — so a Vault is loot-rich and a Den
// is monster-dense, while the entrance room stays monster-free.
for (i, room) in rooms.iter().enumerate() {
let (treasure_bias, monster_bias) =
room.theme.map(RegionTheme::biases).unwrap_or((1.0, 1.0));
let treasure_p = (self.cfg.treasure_chance * treasure_bias).clamp(0.0, 1.0);
if rng.chance(treasure_p) {
if let Some(p) = choose_free(room, &taken, rng) {
taken.insert((p.x, p.y));
entities.push(Entity::new(EntityKind::Treasure, p));
}
}
// Keep the entrance room clear of monsters.
let monster_p = (self.cfg.monster_chance * monster_bias).clamp(0.0, 1.0);
if i != entrance_idx && rng.chance(monster_p) {
let n = rng.range(1, self.cfg.max_monsters.max(1) + 1);
for _ in 0..n {
match choose_free(room, &taken, rng) {
Some(p) => {
taken.insert((p.x, p.y));
entities.push(Entity::new(EntityKind::Monster, p));
}
None => break, // room is full
}
}
}
}
}
ctx.blackboard.insert(BLACKBOARD_KEY, entities);
}
}
/// The room's free floor cell nearest `target`, or [`None`] if none are free.
fn nearest_free(room: &RoomInfo, target: Point, taken: &BTreeSet<(i32, i32)>) -> Option<Point> {
room.floor
.iter()
.copied()
.filter(|p| !taken.contains(&(p.x, p.y)))
.min_by_key(|p| (p.x - target.x).pow(2) + (p.y - target.y).pow(2))
}
/// A uniformly random free floor cell of the room, drawn from `rng`, or [`None`].
fn choose_free(room: &RoomInfo, taken: &BTreeSet<(i32, i32)>, rng: &mut Rng) -> Option<Point> {
let free: Vec<Point> = room
.floor
.iter()
.copied()
.filter(|p| !taken.contains(&(p.x, p.y)))
.collect();
rng.choose(&free).copied()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blackboard::Blackboard;
use crate::geometry::Rect;
use crate::grid::Grid;
use crate::region::ConnGraph;
/// Builds a context with `rooms` rectangular Floor rooms (each `(x,y,w,h)`).
fn ctx_with_rooms(w: u32, h: u32, rooms: &[(i32, i32, i32, i32)]) -> GenContext {
let mut ctx = GenContext {
tiles: Grid::new(w, h, Tile::Wall),
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
for &(x, y, rw, rh) in rooms {
let bounds = Rect::new(x, y, rw, rh);
let cells: Vec<Point> = bounds.iter().collect();
for &p in &cells {
ctx.tiles.set(p, Tile::Floor);
}
ctx.add_region(RegionKind::Room, bounds, cells);
}
ctx
}
fn run(ctx: &mut GenContext, cfg: EntityConfig, seed: u64) -> Vec<Entity> {
let mut rng = Rng::from_seed(seed).fork("entity_placer#0");
EntityPlacer::new(cfg).apply(ctx, &mut rng);
ctx.blackboard.take::<Vec<Entity>>(BLACKBOARD_KEY).unwrap_or_default()
}
#[test]
fn name_is_entity_placer() {
assert_eq!(EntityPlacer::new(EntityConfig::default()).name(), "entity_placer");
}
/// A multi-room map gets exactly one entrance and one exit, on distinct floor
/// cells, and every entity sits on a Floor cell with no two sharing a cell.
#[test]
fn places_one_entrance_one_exit_on_distinct_floor() {
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8)];
let mut ctx = ctx_with_rooms(32, 24, &rooms);
let entities = run(&mut ctx, EntityConfig::default(), 0x1234);
let entrances = entities.iter().filter(|e| e.kind == EntityKind::Entrance).count();
let exits = entities.iter().filter(|e| e.kind == EntityKind::Exit).count();
assert_eq!(entrances, 1, "exactly one entrance");
assert_eq!(exits, 1, "exactly one exit");
// Every entity on Floor; all on distinct cells.
let mut cells = BTreeSet::new();
for e in &entities {
assert_eq!(ctx.tiles.get(e.at), Some(&Tile::Floor), "entity {e:?} not on Floor");
assert!(cells.insert((e.at.x, e.at.y)), "two entities share cell {:?}", e.at);
}
// Entrance and exit are different cells (different rooms here).
let entrance = entities.iter().find(|e| e.kind == EntityKind::Entrance).unwrap();
let exit = entities.iter().find(|e| e.kind == EntityKind::Exit).unwrap();
assert_ne!(entrance.at, exit.at);
}
/// No monster shares the entrance room; the entrance room is a safe start.
#[test]
fn entrance_room_has_no_monsters() {
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8)];
let mut ctx = ctx_with_rooms(32, 24, &rooms);
// Force monsters everywhere they're allowed.
let cfg = EntityConfig { treasure_chance: 0.0, monster_chance: 1.0, max_monsters: 3 };
let entities = run(&mut ctx, cfg, 7);
let entrance = entities.iter().find(|e| e.kind == EntityKind::Entrance).unwrap().at;
// Which room contains the entrance?
let in_room = |p: Point, r: (i32, i32, i32, i32)| Rect::new(r.0, r.1, r.2, r.3).contains(p);
let entrance_room = rooms.iter().copied().find(|&r| in_room(entrance, r)).unwrap();
for m in entities.iter().filter(|e| e.kind == EntityKind::Monster) {
assert!(!in_room(m.at, entrance_room), "monster {m:?} spawned in the entrance room");
}
}
/// Same seed reproduces the exact same entity layer.
#[test]
fn placement_is_deterministic() {
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8), (20, 12, 8, 8)];
let cfg = EntityConfig::default();
let mut a = ctx_with_rooms(32, 24, &rooms);
let mut b = ctx_with_rooms(32, 24, &rooms);
assert_eq!(run(&mut a, cfg, 0x5EED), run(&mut b, cfg, 0x5EED));
}
/// An empty map (no rooms) places no entities and does not panic.
#[test]
fn no_rooms_places_nothing() {
let mut ctx = ctx_with_rooms(8, 8, &[]);
assert!(run(&mut ctx, EntityConfig::default(), 1).is_empty());
}
/// Theme biases scale per-room density: a `Threshold` room (monster_bias 0.0)
/// gets no monsters even at monster_chance 1.0, while a `Vault`
/// (treasure_bias 2.5) is guaranteed treasure when its scaled chance saturates
/// to 1.0. A `None` theme stays neutral.
#[test]
fn theme_biases_scale_treasure_and_monsters() {
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8)];
let mut ctx = ctx_with_rooms(32, 24, &rooms);
// Entrance (geometry) = room 0; exit = room 1. Theme room 1 a Threshold
// (monster_bias 0.0) and room 2 a Vault (treasure_bias 2.5).
ctx.regions[1].theme = Some(RegionTheme::Threshold);
ctx.regions[2].theme = Some(RegionTheme::Vault);
// 0.4 treasure * 2.5 Vault = 1.0 (guaranteed); 1.0 monster * 0.0 = 0.0.
let cfg = EntityConfig { treasure_chance: 0.4, monster_chance: 1.0, max_monsters: 3 };
let entities = run(&mut ctx, cfg, 0xBADF00D);
let in_room = |p: Point, r: (i32, i32, i32, i32)| Rect::new(r.0, r.1, r.2, r.3).contains(p);
// The Threshold room (room 1) holds no monsters despite monster_chance 1.0.
let monsters_in_room1 = entities
.iter()
.filter(|e| e.kind == EntityKind::Monster && in_room(e.at, rooms[1]))
.count();
assert_eq!(monsters_in_room1, 0, "monster_bias 0.0 must suppress all monsters");
// The Vault room (room 2) is guaranteed a treasure (scaled chance == 1.0).
let treasure_in_room2 = entities
.iter()
.any(|e| e.kind == EntityKind::Treasure && in_room(e.at, rooms[2]));
assert!(treasure_in_room2, "treasure_bias 2.5 must guarantee Vault treasure");
}
}