//! 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` 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, /// The room's theme, if a themer classified it (drives content biases). theme: Option, } 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 = 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 = 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 = rooms.iter().map(|r| r.center).collect(); if let Some((entrance_idx, exit_idx)) = entrance_exit_indices(¢ers) { // 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 { 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 { let free: Vec = 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 = 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 { let mut rng = Rng::from_seed(seed).fork("entity_placer#0"); EntityPlacer::new(cfg).apply(ctx, &mut rng); ctx.blackboard.take::>(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"); } }