use crate::floor::Floor; use crate::fov::{color_to_rgb, FovCaster, Lighting}; use crate::glyph::Glyph; use crate::utils::{Direction, Point}; use crate::utils::{ObjectId, RegistryValue}; use std::collections::{HashMap, HashSet}; use crate::portal::Portal; use crate::tile::{DrawLayer, Hookable, IntoTile, LocatedObject, ScriptAttributes, Sensor, Tile, TileSpec}; /// The complete state of one game board (a single room or screen). /// /// `Board` is the central data structure of the engine, equivalent to a /// "board" in ZZT. It contains everything needed to represent and run one /// self-contained area of the game world: /// /// - A grid of cells, each with a visual representation ([`Glyph`]) and an [`Archetype`] /// - The current player position /// - Scripted objects and portals (loaded but not yet active) /// /// ## Cell storage /// /// Cells are stored as `(Glyph, Archetype)` tuples in a row-major `Vec`. /// Each cell directly owns its visual and behavioral class — there is no /// separate element palette or index indirection. Access cells with /// [`Board::get`] and [`Board::get_mut`] using `(x, y)` coordinates. /// Use [`Board::is_passable`] for collision checks. /// /// `Board` derives [`Clone`] to support deep-copying a whole [`World`](crate::world::World) /// (see [`World::deep_clone`](crate::world::World::deep_clone)) — e.g. the editor's /// playtest runs a game against an isolated copy so play mutations never touch the /// boards being edited. #[derive(Clone)] pub struct Board { /// Human-readable name for this board, loaded from the map file and round-tripped on save. pub name: String, /// Width of the board in cells. pub width: usize, /// Height of the board in cells. pub height: usize, /// The single row-major grid of `Option` cells (`width * height`), /// holding every solid. A transparent cell (glyph tile 0) /// draws nothing, revealing a `Sensor` or the /// [`floor`](Board::floor) beneath. Access a cell with [`Board::get`]/ /// [`Board::get_mut`] by `(x, y)`. pub(crate) grid: Vec>, /// The board's cosmetic floor (blank / one fixed glyph / a biome), drawn beneath /// everything. pub(crate) floor: Floor, /// Non-solid things placed off the main grid, can't affect movement but see other hooks pub sensors: Vec, /// The portals aren't really a kind of sensor, and they can't be on the grid because the player /// can share a space with them: pub portals: Vec, /// The next [`ObjectId`] to hand out (starts at 1, monotonically increasing). /// See [`Board::add_object`]. pub next_object_id: ObjectId, /// When `true`, this board is "dark": front-ends reveal only the cells the /// player can see and that receive light (see [`Board::lighting`]) and draw /// everything else as unlit darkness. Sight and light are blocked by opaque cells. /// Loaded from / saved to the `dark` key in the map file's `[map]` header; /// defaults to `false` (fully lit). pub dark: bool, /// Per-board key→value store written and read by Rhai scripts via the /// `Registry` scope constant. Persists automatically across board transitions /// because all boards live as `Rc>` in `World::boards` and are /// never evicted. Not saved to disk in v1. pub registry: HashMap, } impl Board { /// Return a list of all `ObjectId`s currently on the board. pub fn all_ids(&self) -> Vec { let mut grid_ids = self.grid.iter().filter_map(|cell| { if let Some(Tile::Object(def)) = cell { Some(def.scripting.id) } else { None } }).collect::>(); grid_ids.extend(self.sensors.iter().map(|s| s.scripting.id)); grid_ids.sort(); grid_ids } /// Returns a reference to the `(Glyph, Archetype)` cell at `(x, y)`. /// /// Panics if `x` or `y` are out of bounds. pub fn get>(&self, p: P) -> &Option { let p = p.into(); &self.grid[p.uy() * self.width + p.ux()] } /// Returns a mutable reference to the cell at `(x, y)`. /// /// Panics if `x` or `y` are out of bounds. pub fn get_mut>(&mut self, p: P) -> &mut Option { let p = p.into(); let w = self.width; &mut self.grid[p.uy() * w + p.ux()] } /// Replace the solid terrain (if any) at `(x, y)` with a transparent `Empty` /// cell, revealing the floor beneath. pub fn clear_cell>(&mut self, p: P) { let p = p.into(); if self.in_bounds(p) { *self.get_mut(p) = None; } } /// Returns the glyph to display at `(x, y)`. /// /// With a single grid the draw order is a fixed precedence (no layer walk): /// /// 1. a sensor `Above` the grid whose glyph is visible (drawn on top of the /// player too); /// 2. the grid cell's tile — the player or an object — when its glyph is /// visible (`tile != 0`, so invisible objects exist); /// 3. a sensor `Below` the grid whose glyph is visible (only reachable /// because the grid cell drew nothing); /// 4. a portal at the cell (portals sit on a transparent grid cell), when /// its glyph is visible; /// 5. the [`floor`](Board::floor) glyph, if any; /// 6. the canonical black `Empty` glyph. /// /// Panics if out of bounds. pub fn glyph_at>(&self, p: P) -> Glyph { let p = p.into(); let grid_glyph = self.get(p).as_ref().map(Tile::glyph); let sensors = self.sensors.iter().filter(|&s| s.location == p); // Is there a sensor above the grid? if let Some(above) = sensors.clone().find(|s| s.draw_layer == DrawLayer::Above && s.scripting.glyph.is_visible()) { return above.scripting.glyph; } // Does the grid have a good glyph? if let Some(glyph) = grid_glyph && glyph.is_visible() { return glyph; } // Is there a sensor below the grid? if let Some(below) = sensors.clone().find(|s| s.draw_layer == DrawLayer::Below && s.scripting.glyph.is_visible()) { return below.scripting.glyph; } // A portal at the cell, if its glyph is visible (a `tile = 0` portal is // deliberately invisible, so the floor shows through instead). if let Some(portal) = self.portal_at(p) && portal.glyph.is_visible() { return portal.glyph; } // Otherwise the floor, or the canonical black empty cell. self.floor.glyph_at(p, self.width).unwrap_or_else(Glyph::transparent) } /// Returns `true` if `(x, y)` is a valid cell coordinate on this board. /// /// Takes signed coords so callers can pass a raw `pos + delta` without first /// checking for negatives. pub fn in_bounds>(&self, pos: P) -> bool { let p = pos.into(); p.x >= 0 && p.y >= 0 && p.ux() < self.width && p.uy() < self.height } /// Returns `true` if cell `(x, y)` blocks line of sight (and light). /// /// A cell is sight-blocking if its grid terrain is opaque (e.g. a `Wall`) /// **or** any object on it is opaque. This is the input to lighting on /// [`dark`](Board::dark) boards; see [`Board::lighting`]. /// Panics if `x` or `y` are out of bounds. pub fn is_opaque_at>(&self, p: P) -> bool { let p = p.into(); if self.sensors.iter().any(|s| s.location == p && s.scripting.optics.opaque) { true } else { match self.get(p) { None => false, Some(Tile::Player) => false, Some(Tile::Object(def)) => { def.scripting.optics.opaque } } } } /// Computes lighting + line-of-sight for the player on this board. /// /// Returns `None` unless the board is [`dark`](Board::dark) — a lit board /// needs no lighting and front-ends draw every cell at full color. On a dark /// board it (a) casts an unbounded line-of-sight field from the player, then /// (b) accumulates colored light from every source — the player's torch /// (radius `player_torch`, white), each object with `light > 0`, and each /// terrain cell with [`Archetype::light`] `> 0` — each source colored by its /// own glyph fg and falling off linearly to its radius. Opaque cells (via /// [`is_opaque_at`](Board::is_opaque_at)) block both sight and light. pub fn lighting(&self, player_torch: u32) -> Option { if !self.dark { return None; } let (w, h) = (self.width, self.height); let mut lighting = Lighting::new(w, h); // One caster whose transparency is seeded once from the opaque cells; // reused for the LOS pass and every light source (its FOV is cleared per cast). let mut caster = FovCaster::new(w, h, |x, y| !self.is_opaque_at((x, y))); let (px, py) = { let p = self.player_pos(); (p.ux(), p.uy()) }; // (a) Player line of sight — unbounded (radius 0), pure geometry. caster.cast(px, py, 0, |x, y| lighting.set_los(x, y)); // (b) Accumulate each light source into the per-cell color buffer. A // source paints every cell it can see within its radius, tinted by its // glyph fg and dimmed by a linear falloff (full at the source, 0 at the edge). let mut add_source = |lighting: &mut Lighting, sx: usize, sy: usize, radius: u32, color: [f32; 3]| { let r = radius as f32; caster.cast(sx, sy, radius as usize, |x, y| { let d = ((x as f32 - sx as f32).powi(2) + (y as f32 - sy as f32).powi(2)).sqrt(); let falloff = (1.0 - d / r).max(0.0); lighting.add_light(x, y, [color[0] * falloff, color[1] * falloff, color[2] * falloff]); }); }; // The player's torch: a white light centered on the player. if player_torch > 0 { add_source(&mut lighting, px, py, player_torch, [1.0, 1.0, 1.0]); } // Glowing terrain (e.g. a `Torch` cell): color = the cell's glyph foreground. for y in 0..h { for x in 0..w { if let Some(Tile::Object(obj)) = self.get((x, y)) { let radius = obj.scripting.optics.glow; if radius > 0 { add_source(&mut lighting, x, y, radius, color_to_rgb(obj.scripting.glyph.fg)); } } } } // Glowing sensors for s in self.sensors.iter() { if s.optics().glow > 0 { add_source(&mut lighting, s.location.ux(), s.location.uy(), s.optics().glow, color_to_rgb(s.scripting.glyph.fg)); } } Some(lighting) } /// Returns the [`ObjectId`]s of the **non-solid** objects at `(x, y)`. /// /// These are the targets of an `enter` hook when a solid relocates onto the /// cell (terrain is always solid, so only objects can be non-solid). Mirrors /// [`object_ids_at`](Board::object_ids_at) / [`solid_object_id_at`](Board::solid_object_id_at). pub fn sensor_ids_at>(&self, p: P) -> Vec { let p = p.into(); self.sensors .iter() .filter(|s| s.location == p) .map(|s| s.scripting.id) .collect() } /// Find and return the portal at the given location pub fn portal_at>(&self, p: P) -> Option<&Portal> { let p = p.into(); self.portals.iter().find(|&portal| portal.location == p) } /// Editor primitive: stamps `arch` (with visual `glyph`) into the cell at /// `(x, y)`, applying the editor's placement/removal rules. /// /// Two cases, keyed only on the archetype (the floor is **never** touched — the /// drawing tools cannot place, remove, or alter a floor): /// /// - **Terrain** (`arch != Empty`, always solid today): removes any solid object /// already in the cell, then writes `(glyph, arch)` into the grid cell. /// - **Erase** (`arch == Empty`): removes the grid cell's terrain *and* every /// object in it, leaving the floor beneath in place. /// /// A vacated grid cell becomes a transparent `Empty` so the floor shows through. /// Panics if `(x, y)` is out of bounds. pub fn place>(&mut self, p: P, spec: Option) -> Result<(), String> { if let Some(spec) = spec { let tile = spec.into_tile(&mut self.next_object_id)?; *self.get_mut(p) = Some(tile); } else { *self.get_mut(p) = None; } Ok(()) } /// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script /// `shift()` fn. Returns a [`ShiftOutcome`] carrying any error [`LogLine`]s for /// the caller to log plus the `(from, to)` relocations it performed (so the /// caller can fire `enter` on non-solids each moved solid landed on). pub fn apply_shift(&mut self, cells: &[Point]) -> Result, String> { // Validate all the cells are in bounds, error if not: if cells.iter().any(|&c| !self.in_bounds(c)) { return Err("Called shift() with a cell out of bounds".to_string()) } // Get all the Solids at these cells: let solids: Vec<_> = cells.iter().map(|&c| self.get_mut(c).take()).collect(); // Find which ones are blockers let mut immobile = HashSet::new(); for (curr_idx, curr) in solids.iter().enumerate() { let pushable = curr.as_ref().is_none_or(Tile::shiftable); if !pushable { immobile.insert(curr_idx); } } // Trace back from each immobile until we find an empty: let mut blocked = HashSet::new(); for curr_idx in immobile { let mut prev_idx = curr_idx; loop { if solids[prev_idx].is_some() && !blocked.contains(&prev_idx) { blocked.insert(prev_idx); prev_idx = (prev_idx + cells.len() - 1) % cells.len(); } else { break } } } // Now, move anything that we've decided is not blocked, recording each // relocation so the caller can fire `enter` at every destination. let mut moves = Vec::new(); for (curr_idx, curr) in solids.into_iter().enumerate() { if let Some(solid) = curr { if !blocked.contains(&curr_idx) { // Not blocked, write it to target let origin = cells[curr_idx]; let target = cells[(curr_idx + 1) % cells.len()]; *self.get_mut(target) = Some(solid); moves.push((origin, target)); } else { // it was blocked so just write it back where it was let origin = cells[curr_idx]; *self.get_mut(origin) = Some(solid); } } } Ok(moves) } /// Clear the queues of all objects on this board: called when entering a board, objects /// don't retain their state across board visits (they get initialized again, but can /// store things in the board registry) pub fn clear_all_queues(&mut self) { for cell in self.grid.iter_mut() { if let Some(Tile::Object(obj)) = cell { obj.scripting.queue.clear() } } } pub fn player_pos(&self) -> Point { self.grid.iter().enumerate().find_map(|(i, cell)| { if matches!(cell, Some(Tile::Player)) { Some((i % self.width, i / self.width).into()) } else { None } }).expect("No player found!") // This should never happen, player presence is validated when building a board } pub fn get_hookable(&self, id: ObjectId) -> Option> { // Search sensors first because it's probably shorter for sensor in self.sensors.iter() { if sensor.scripting.id == id { return Some(Box::new(sensor)) } } for (i, tile) in self.grid.iter().enumerate() { if let Some(Tile::Object(obj)) = tile && obj.scripting.id == id { return Some(Box::new(LocatedObject(obj, (i % self.width, i / self.width).into()))) } } None } pub fn get_named(&self, name: &str) -> Option> { // Search sensors first because it's probably shorter for sensor in self.sensors.iter() { if let Some(n) = sensor.scripting.name.as_ref() && n.as_str() == name { return Some(Box::new(sensor)) } } for (i, tile) in self.grid.iter().enumerate() { if let Some(Tile::Object(obj)) = tile && let Some(n) = obj.scripting.name.as_ref() && n.as_str() == name { return Some(Box::new(LocatedObject(obj, (i % self.width, i / self.width).into()))) } } None } pub fn get_tagged(&self, tag: &str) -> Vec> { self.sorted_hookables().into_iter().filter(|hookable| { hookable.scriptable().tags.contains(tag) }).collect() } pub fn scripting_mut(&mut self, id: ObjectId) -> Option<&mut ScriptAttributes> { for sensor in self.sensors.iter_mut() { if sensor.scripting.id == id { return Some(&mut sensor.scripting) } } for tile in self.grid.iter_mut() { if let Some(Tile::Object(obj)) = tile && obj.scripting.id == id { return Some(&mut obj.scripting) } } None } pub fn sorted_hookables(&self) -> Vec> { // Collect all the sensors let mut hookables = self.sensors.iter().map(|s| Box::new(s) as Box).collect::>(); // Add the objects into it for (i, tile) in self.grid.iter().enumerate() { if let Some(Tile::Object(obj)) = tile { hookables.push(Box::new(LocatedObject(obj, (i % self.width, i / self.width).into()))) } } // Sort by id hookables.sort_by_key(|a| a.id()); hookables } pub fn remove_object(&mut self, id: ObjectId) { if let Some(tile) = self.grid.iter_mut().find(|cell| { matches!(cell, Some(Tile::Object(obj)) if obj.scripting.id == id) }) { tile.take(); } else { self.sensors.retain(|s| s.id() != id); } } pub fn named_portal(&self, name: &str) -> Option<&Portal> { self.portals.iter().find(|p| p.name == name) } pub fn move_sensor(&mut self, id: ObjectId, dir: Direction) { if let Some(sensor_idx) = self.sensors.iter().position(|s| s.scripting.id == id) { let new_loc = self.sensors[sensor_idx].location.in_dir(dir); if self.in_bounds(new_loc) { self.sensors[sensor_idx].location = new_loc } } } /// Moves whatever is in `from` to `to`, leaving an empty cell behind. Silent no-op if either /// `from` or `to` is out of bounds, or if they're the same cell. pub fn move_cell, P2: Into>(&mut self, from: P1, to: P2) { let from = from.into(); let to = to.into(); if from != to && self.in_bounds(from) && self.in_bounds(to) { let thing = self.get_mut(from).take(); *self.get_mut(to) = thing; } } /// Return whether the given point is empty pub fn is_empty>(&self, p: P) -> bool { self.get(p).is_none() } /// Return whether the given point contains the player pub fn is_player>(&self, p: P) -> bool { matches!(self.get(p), Some(Tile::Player)) } /// Return whether the given point contains an object pub fn is_object>(&self, p: P) -> bool { matches!(self.get(p), Some(Tile::Object(_))) } /// Return if there's an object at the given point and if it's mobile pub fn is_mobile>(&self, p: P) -> bool { if let Some(Tile::Object(o)) = self.get(p) { o.mobile } else { false } } } #[cfg(test)] pub(crate) mod tests { use super::Board; use crate::builtin::Builtin; use crate::floor::Floor; use crate::glyph::Glyph; use crate::utils::ObjectId; use color::Rgba8; use std::collections::HashMap; use crate::object_def::ObjectDef; use crate::tile::{ DrawLayer, IntoTile, Optics, ScriptAttributes, Sensor, SensorSpec, Tile, TileSpec, }; /// Builds an all-empty `w×h` board. /// /// The grid is fully transparent (a blank floor), so terrain stamped via /// [`crate_at`] etc. lands on the single grid. Use [`add_floor`] to give the /// board a visible fixed floor underneath. pub(crate) fn open_board( w: usize, h: usize, player_pos: (usize, usize) ) -> Board { let mut board = Board { name: "test".into(), width: w, height: h, grid: vec![None; w * h], floor: Floor::Blank, sensors: Vec::new(), portals: Vec::new(), next_object_id: 1, dark: false, registry: HashMap::new(), }; player_at(&mut board, player_pos.0, player_pos.1); board } /// Gives the board a uniform fixed floor glyph (the single-grid replacement for /// the old separate floor layer). pub(crate) fn add_floor(board: &mut Board, glyph: Glyph) { board.floor = Floor::Fixed(glyph); } /// Stamps a crate cell onto the grid. pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) { *board.get_mut((x, y)) = Some(TileSpec::krate().into_tile(&mut board.next_object_id).unwrap()); } /// Stamps a wall cell onto the grid. pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) { *board.get_mut((x, y)) = Some(TileSpec::wall().into_tile(&mut board.next_object_id).unwrap()); } /// Stamps a gem cell onto the grid. pub(crate) fn gem_at(board: &mut Board, x: usize, y: usize) { *board.get_mut((x, y)) = Some(TileSpec::gem().into_tile(&mut board.next_object_id).unwrap()); } /// Stamps the builtin named `kind` (any alias accepted by [`Builtin::from_name`], /// e.g. `"spinner_cw"`, `"pusher_east"`) onto the grid, returning its id. /// /// The generic counterpart to [`crate_at`]/[`wall_at`]/[`gem_at`]: `into_tile` /// attaches the family's [`ScriptKey`](crate::tile::ScriptKey) and the /// `BUILTIN_` tag, so the object is fully live with no world script pool. /// Panics on an unknown `kind`. pub(crate) fn builtin_at(board: &mut Board, x: usize, y: usize, kind: &str) -> ObjectId { let tile = TileSpec::Builtin { kind: kind.to_string(), glyph: None } .into_tile(&mut board.next_object_id) .unwrap_or_else(|e| panic!("{e}")); let id = match &tile { Tile::Object(obj) => obj.scripting.id, Tile::Player => unreachable!("a builtin never resolves to the player"), }; *board.get_mut((x, y)) = Some(tile); id } /// Stamps a scripted object at `(x, y)` running the world script named `script`, /// answering entry attempts with `enter`, and returns its id. /// /// The on-grid counterpart to [`sensor_at`]: this object occupies its cell and /// takes part in collision, so `enter` decides how it responds to something /// moving into it (`Block` for an ordinary solid, `Push(..)` for a shovable one). pub(crate) fn object_at( board: &mut Board, x: usize, y: usize, script: &str, mobile: bool, ) -> ObjectId { let tile = TileSpec::Object { script: Some(script.to_string()), mobile, glyph: ObjectDef::default_glyph(), optics: Optics { opaque: true, glow: 0 }, name: None, tags: Vec::new(), } .into_tile(&mut board.next_object_id) .expect("an object spec always resolves"); let id = match &tile { Tile::Object(obj) => obj.scripting.id, Tile::Player => unreachable!("an object spec never resolves to the player"), }; *board.get_mut((x, y)) = Some(tile); id } /// Stamps an object at `(x, y)` with **no script attached**, returning its id. /// /// For the "an object without a script is inert" path, and for plain physical /// props (a pushable block with no behavior of its own). Everything scripted /// should use [`object_at`]. pub(crate) fn plain_object_at( board: &mut Board, x: usize, y: usize, mobile: bool, ) -> ObjectId { let tile = TileSpec::Object { script: None, mobile, glyph: ObjectDef::default_glyph(), optics: Optics { opaque: true, glow: 0 }, name: None, tags: Vec::new(), } .into_tile(&mut board.next_object_id) .expect("an object spec always resolves"); let id = match &tile { Tile::Object(obj) => obj.scripting.id, Tile::Player => unreachable!("an object spec never resolves to the player"), }; *board.get_mut((x, y)) = Some(tile); id } /// Adds an invisible, script-only [`Sensor`] at `(x, y)` running the world script /// named `script`, returning its id. /// /// This is how a test gets "a scripted thing that doesn't get in the way": every /// object on the grid is solid now, so a script host that must not block movement /// (or must share a cell) has to live off-grid in [`Board::sensors`]. pub(crate) fn sensor_at(board: &mut Board, x: usize, y: usize, script: &str) -> ObjectId { let sensor = SensorSpec { location: (x, y).into(), script: Some(script.to_string()), glyph: Glyph::transparent(), optics: Optics::default(), name: None, tags: Vec::new(), draw_layer: DrawLayer::Below, } .into_sensor(&mut board.next_object_id); let id = sensor.scripting.id; board.sensors.push(sensor); id } /// Stamps a player cell onto the grid. pub(crate) fn player_at(board: &mut Board, x: usize, y: usize) { *board.get_mut((x, y)) = Some(TileSpec::player().into_tile(&mut board.next_object_id).unwrap()); } pub(crate) fn lamp_at(board: &mut Board, x: usize, y: usize) { let lamp = Sensor { location: (x, y).into(), draw_layer: DrawLayer::Above, scripting: ScriptAttributes { id: board.next_object_id, glyph: Glyph { tile: '☺', fg: Rgba8 { r: 255, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } }, optics: Optics { glow: 4, opaque: false }, ..Default::default() } }; board.next_object_id += 1; board.sensors.push(lamp); } pub(crate) fn is_builtin(board: &Board, x: usize, y: usize, tag: &str) -> bool { if let Some(Tile::Object(obj)) = board.get((x, y)) { obj.scripting.tags.contains(&format!("BUILTIN_{tag}")) } else { false } } #[test] fn in_bounds_checks_grid_boundaries() { let board = open_board(3, 2, (0, 0)); assert!(board.in_bounds((0, 0))); assert!(board.in_bounds((2, 1))); assert!(!board.in_bounds((-1, 0))); assert!(!board.in_bounds((0, -1))); assert!(!board.in_bounds((3, 0))); assert!(!board.in_bounds((0, 2))); } #[test] fn glyph_at_uses_floor_for_empty_and_grid_for_solid() { // Player parked at (2,0) so it doesn't overlap either asserted cell. let mut board = open_board(3, 1, (2, 0)); let floor_glyph = Glyph { tile: '.', fg: Rgba8 { r: 10, g: 20, b: 30, a: 255, }, bg: Rgba8 { r: 1, g: 2, b: 3, a: 255, }, }; // A fixed floor attribute, a wall on the grid at (0,0). add_floor(&mut board, floor_glyph); wall_at(&mut board, 0, 0); // The wall (solid) draws over the floor; the empty cell reveals the floor. assert_eq!(board.glyph_at((0, 0)), Builtin::Wall.default_glyph_for("wall")); assert_eq!(board.glyph_at((1, 0)), floor_glyph); } #[test] fn apply_shift_out_of_bounds_rejects_immediately() { // apply_shift validates all cells upfront; any out-of-bounds cell causes immediate failure. let mut board = open_board(3, 1, (2, 0)); crate_at(&mut board, 0, 0); let errs = board.apply_shift(&[(0, 0).into(), (9, 0).into()]); assert!(errs.is_err()); assert!(is_builtin(&board, 0, 0, "crate")); // unchanged } #[test] fn apply_shift_wall_stops_cascade_but_empty_limits_it() { // A wall is immobile (`mobile = false`). Backward cascade from the wall traces // through preceding solids until it hits empty, marking those as blocked. // Solids on the *other* side of the empty (outside the blocked region) still move. let mut board = open_board(6, 1, (5, 0)); crate_at(&mut board, 0, 0); // (1,0) stays empty wall_at(&mut board, 2, 0); crate_at(&mut board, 3, 0); crate_at(&mut board, 4, 0); // Cycle: idx 0 → idx 1 → idx 2 → idx 3 → idx 4 → idx 0 (wrap) // immobile = {2} (the wall: Tile::shiftable() is false) // Backward trace from idx 2: solids[2]=Some → blocked.insert(2), prev=1 // solids[1]=None (empty) → break // blocked = {2}; Crate at (0,0) is NOT blocked // Result: Crate(0,0)→(1,0), empty→no-op, Wall stays at (2,0), // Crate(3,0)→(4,0), Crate(4,0)→(0,0) wrap let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)].map(Into::into)); assert!(is_builtin(&board, 0, 0, "crate")); // wrapped from (4,0) assert!(is_builtin(&board, 1, 0, "crate")); // moved from (0,0) assert!(is_builtin(&board, 2, 0, "wall")); // blocked, immobile assert!(board.get((3, 0)).is_none()); // cleared, crate moved assert!(is_builtin(&board, 4, 0, "crate")); // moved from (3,0) } #[test] fn lighting_none_when_not_dark() { // A lit board needs no lighting; front-ends draw every cell. let board = open_board(5, 1, (0, 0)); assert!(!board.dark); assert!(board.lighting(10).is_none()); } #[test] fn wall_is_opaque_empty_is_not() { let mut board = open_board(3, 1, (0, 0)); wall_at(&mut board, 1, 0); assert!(board.is_opaque_at((1, 0))); // wall blocks sight assert!(!board.is_opaque_at((2, 0))); // empty cell is transparent } #[test] fn dark_board_hides_cells_behind_a_wall() { // Player at the left end of a 1-wide corridor; a wall at x=2 occludes // everything past it. The player's torch lights cells before the wall // (and the wall itself); the cells behind the wall are neither lit nor // in line of sight, so they are not visible. let mut board = open_board(5, 1, (0, 0)); board.dark = true; wall_at(&mut board, 2, 0); let lit = board.lighting(10).expect("dark board yields Lighting"); assert!(lit.is_visible(0, 0)); // the player's own cell assert!(lit.is_visible(1, 0)); // open cell before the wall assert!(lit.is_visible(2, 0)); // the wall itself (light_walls = true) assert!(!lit.is_visible(3, 0)); // occluded behind the wall assert!(!lit.is_visible(4, 0)); // occluded behind the wall } #[test] fn unlit_cell_in_sight_is_not_visible() { // A long lit-free corridor: with a tiny torch, far cells are in line of // sight but receive no light, so they are not visible (LOS ∩ lit). let mut board = open_board(10, 1, (0, 0)); board.dark = true; let lit = board.lighting(2).expect("dark board yields Lighting"); assert!(lit.is_visible(0, 0)); // at the torch assert!(lit.is_visible(1, 0)); // within the torch radius assert!(!lit.is_visible(8, 0)); // in sight but unlit → dark } #[test] fn object_light_tints_toward_its_color() { // A dark board with no player torch and one red-glyph light object: the // object's cell is lit red, so a white base tints red (green/blue killed). let mut board = open_board(3, 1, (1, 0)); board.dark = true; lamp_at(&mut board, 1, 0); let lit = board.lighting(0).expect("dark board yields Lighting"); // no player torch let white = Rgba8 { r: 255, g: 255, b: 255, a: 255 }; let t = lit.tint(1, 0, white); assert!(t.r > 0, "red channel survives"); assert_eq!(t.g, 0, "green killed by red light"); assert_eq!(t.b, 0, "blue killed by red light"); } }