Files
kiln/kiln-core/src/board.rs
T
2026-08-11 23:01:12 -05:00

832 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::floor::Floor;
use crate::fov::{color_to_rgb, FovCaster, Lighting};
use crate::glyph::Glyph;
use crate::log::LogLine;
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<Tile>` 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<Option<Tile>>,
/// 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<Sensor>,
/// 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<Portal>,
/// 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<RefCell<Board>>` in `World::boards` and are
/// never evicted. Not saved to disk in v1.
pub registry: HashMap<String, RegistryValue>,
}
impl Board {
/// Return a list of all `ObjectId`s currently on the board.
pub fn all_ids(&self) -> Vec<ObjectId> {
let mut grid_ids = self.grid.iter().filter_map(|cell| {
if let Some(Tile::Object(def)) = cell {
Some(def.scripting.id)
} else {
None
}
}).collect::<Vec<_>>();
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<P: Into<Point>>(&self, p: P) -> &Option<Tile> {
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<P: Into<Point>>(&mut self, p: P) -> &mut Option<Tile> {
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<P: Into<Point>>(&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. the player (drawn on top; not part of the grid yet);
/// 2. an object at the cell — a solid object always, otherwise the first
/// non-transparent non-solid object (`tile != 0`, so invisible objects exist);
/// 3. the grid cell `(glyph, arch)` — a solid always draws, a non-solid only when
/// visible (`tile != 0`);
/// 4. a portal at the cell (portals sit on a transparent grid cell);
/// 5. a [`decoration`](Board::decorations) at the cell (reached only because the
/// grid cell was empty);
/// 6. the [`floor`](Board::floor) glyph, if any;
/// 7. the canonical black `Empty` glyph.
///
/// Panics if out of bounds.
pub fn glyph_at<P: Into<Point>>(&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;
}
// 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<P: Into<Point>>(&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<P: Into<Point>>(&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<Lighting> {
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<P: Into<Point>>(&self, p: P) -> Vec<ObjectId> {
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<P: Into<Point>>(&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<P: Into<Point>>(&mut self, p: P, spec: Option<TileSpec>) -> 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<Vec<(Point, Point)>, 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().map_or(true, |c| c.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<Box<dyn Hookable + '_>> {
// 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<Box<dyn Hookable + '_>> {
// 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<Box<dyn Hookable + '_>> {
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<Box<dyn Hookable + '_>> {
// Collect all the sensors
let mut hookables = self.sensors.iter().map(|s| Box::new(s) as Box<dyn Hookable>).collect::<Vec<_>>();
// 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(|a, b| a.id().cmp(&b.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().enumerate().find(|(idx, 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<P1: Into<Point>, P2: Into<Point>>(&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<P: Into<Point>>(&self, p: P) -> bool {
self.get(p).is_none()
}
/// Return whether the given point contains the player
pub fn is_player<P: Into<Point>>(&self, p: P) -> bool {
matches!(self.get(p), Some(Tile::Player))
}
/// Return whether the given point contains an object
pub fn is_object<P: Into<Point>>(&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<P: Into<Point>>(&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_<kind>` 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");
}
}