Files
kiln/kiln-core/src/board.rs
T

1299 lines
55 KiB
Rust
Raw Normal View History

2026-06-07 00:19:53 -05:00
use crate::archetype::Archetype;
use crate::glyph::Glyph;
2026-06-15 23:35:18 -05:00
use crate::layer::Layer;
2026-06-07 00:19:53 -05:00
use crate::log::LogLine;
use crate::object_def::ObjectDef;
2026-06-08 22:15:44 -05:00
use crate::utils::Direction;
2026-06-13 17:58:04 -05:00
use crate::utils::{ObjectId, Player, PortalDef, RegistryValue, Solid};
2026-06-21 01:32:47 -05:00
use std::collections::{BTreeMap, HashMap, HashSet};
2026-06-21 18:27:45 -05:00
use crate::builtin_scripts::archetype_script_key;
2026-06-21 01:32:47 -05:00
/// A captured solid occupant of a cell, used by [`Board::apply_swap`] to read
/// every source cell before writing any destination (so cyclic moves work).
#[derive(Clone)]
enum SolidSnapshot {
/// The player occupied the cell.
Player,
/// A solid scripted object occupied the cell, identified by its stable id.
Object(ObjectId),
/// A solid terrain cell — its visual/behavior plus the layer it lived on.
Terrain {
/// Layer the terrain lived on (restored on the destination).
z: usize,
/// The cell's glyph.
glyph: Glyph,
/// The cell's archetype.
arch: Archetype,
},
/// No solid occupant.
Empty,
}
2026-06-07 00:19:53 -05:00
/// 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.
2026-06-20 19:05:46 -05:00
///
/// `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)]
2026-06-07 00:19:53 -05:00
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,
2026-06-15 23:35:18 -05:00
/// Ordered draw stack of [`Layer`]s, bottom (index 0) to top. Each layer holds
/// a row-major grid of `(Glyph, Archetype)` cells; a transparent cell lets the
/// layer beneath show through. Drawing ([`Board::glyph_at`]) walks the stack
/// top-down; solidity ([`Board::solid_at`]) scans every layer. Access a single
/// cell with [`Board::get`]/[`Board::get_mut`] by `(z, x, y)`.
pub(crate) layers: Vec<Layer>,
2026-06-07 00:19:53 -05:00
/// Current player position. See [`Player`] for caveats about its future.
pub player: Player,
/// Scripted objects on this board, keyed by stable [`ObjectId`]. A `BTreeMap`
/// (not a `Vec`) so an object can be removed without invalidating other
/// objects' ids; iteration is in ascending-id order, which equals load order
/// (ids are assigned sequentially as the map loads).
pub objects: BTreeMap<ObjectId, ObjectDef>,
/// The next [`ObjectId`] to hand out (starts at 1, monotonically increasing).
/// See [`Board::add_object`].
pub next_object_id: ObjectId,
/// Portals on this board. Parsed from the map file; not yet active.
pub portals: Vec<PortalDef>,
2026-06-13 01:25:58 -05:00
/// Name of the board-level script in the world script pool, if any.
2026-06-07 00:19:53 -05:00
///
/// A board script runs on the board as a whole (e.g. `on_enter`, `on_tick`)
2026-06-13 01:25:58 -05:00
/// rather than being tied to a specific object cell. Scripts live in
/// [`World::scripts`](crate::world::World) and are looked up by this name.
2026-06-07 00:19:53 -05:00
pub board_script_name: Option<String>,
/// Nonfatal problems collected while loading this map (e.g. unknown
/// archetypes, dropped objects, recovered placement chars), as red-on-black
/// [`LogLine`]s. Empty for a clean load; see [`Board::is_valid`]. Not part of
/// the map file (purely a load diagnostic).
pub(crate) load_errors: Vec<LogLine>,
2026-06-13 17:58:04 -05:00
/// 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>,
2026-06-07 00:19:53 -05:00
}
impl Board {
2026-06-15 23:35:18 -05:00
/// Number of draw layers on this board (≥ 1 for a loaded board).
pub fn layer_count(&self) -> usize {
self.layers.len()
}
/// Returns a reference to the cell at `(x, y)` on layer `z`.
2026-06-07 00:19:53 -05:00
///
2026-06-15 23:35:18 -05:00
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `z`, `x`, or `y` are
2026-06-07 00:19:53 -05:00
/// out of bounds.
2026-06-15 23:35:18 -05:00
pub fn get(&self, z: usize, x: usize, y: usize) -> &(Glyph, Archetype) {
&self.layers[z].cells[y * self.width + x]
2026-06-07 00:19:53 -05:00
}
2026-06-15 23:35:18 -05:00
/// Returns a mutable reference to the cell at `(x, y)` on layer `z`.
2026-06-07 00:19:53 -05:00
///
2026-06-15 23:35:18 -05:00
/// Panics if `z`, `x`, or `y` are out of bounds.
pub fn get_mut(&mut self, z: usize, x: usize, y: usize) -> &mut (Glyph, Archetype) {
let w = self.width;
&mut self.layers[z].cells[y * w + x]
2026-06-07 00:19:53 -05:00
}
2026-06-15 23:35:18 -05:00
/// Returns the glyph to display at `(x, y)`, honoring layer draw order.
///
/// The player is always drawn on top (it is not part of the layer stack yet).
/// Otherwise the layers are walked **top-down**; the first thing that draws on
/// a layer wins:
2026-06-07 00:19:53 -05:00
///
2026-06-15 23:35:18 -05:00
/// 1. an object on that layer (a solid object always; otherwise a non-solid
/// object whose glyph is not transparent, i.e. `tile != 0`),
/// 2. a portal on that layer,
/// 3. the layer's terrain cell — a solid always draws, and a non-solid draws
/// only when not transparent (`tile != 0`).
2026-06-07 00:19:53 -05:00
///
2026-06-15 23:35:18 -05:00
/// If no layer contributes anything, the canonical black `Empty` glyph is used.
/// Panics if out of bounds.
2026-06-07 00:19:53 -05:00
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
2026-06-15 23:35:18 -05:00
// The player is rendered above the whole stack (see the `Player` notes).
if self.player.x == x as i32 && self.player.y == y as i32 {
return Glyph::player();
2026-06-07 00:19:53 -05:00
}
2026-06-15 23:35:18 -05:00
for z in (0..self.layers.len()).rev() {
// Objects on this layer: a solid object always draws; otherwise the
// first non-transparent non-solid object (lets invisible objects exist).
let mut nonsolid: Option<Glyph> = None;
for o in self
.objects
.values()
.filter(|o| o.x == x && o.y == y && o.z == z)
{
if o.solid {
return o.glyph;
}
if nonsolid.is_none() && o.glyph.tile != 0 {
nonsolid = Some(o.glyph);
}
}
if let Some(g) = nonsolid {
return g;
}
2026-06-07 00:19:53 -05:00
2026-06-15 23:35:18 -05:00
// A portal on this layer draws above its (transparent) terrain cell.
if self
.portals
.iter()
.any(|p| p.x == x && p.y == y && p.z == z)
{
return PortalDef::default_glyph();
}
2026-06-07 00:19:53 -05:00
2026-06-15 23:35:18 -05:00
// The terrain cell: a solid always draws; a non-solid only if visible.
let (glyph, arch) = self.get(z, x, y);
if arch.behavior().solid || glyph.tile != 0 {
return *glyph;
}
2026-06-13 16:24:29 -05:00
}
2026-06-15 23:35:18 -05:00
// Nothing on any layer: the canonical black empty cell.
Archetype::Empty.default_glyph()
2026-06-07 00:19:53 -05:00
}
/// 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: (i32, i32)) -> bool {
let (x, y) = pos;
x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height
}
/// Records a nonfatal error: appends `message` as a red-on-black line to the
/// board's [`load_errors`](Board::load_errors). Used by the map loader (and
/// available at runtime) to surface recoverable problems.
pub fn report_error(&mut self, message: impl Into<String>) {
self.load_errors.push(LogLine::error(message));
}
/// Returns `true` if the map loaded with no nonfatal errors (the error list
/// is empty).
pub fn is_valid(&self) -> bool {
self.load_errors.is_empty()
}
2026-06-15 23:35:18 -05:00
2026-06-07 00:19:53 -05:00
/// Returns the single solid entity occupying `(x, y)`, if any.
///
/// Checks player first, then objects, then the grid archetype. Because at most one solid
/// may occupy a cell (an invariant enforced when the board is loaded — see
/// [`crate::map_file`]), this returns that one occupant or `None`.
/// Panics if `x` or `y` are out of bounds.
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid> {
// The player wins its cell (load-time invariant), so it is the solid there.
if self.player.x == x as i32 && self.player.y == y as i32 {
return Some(Solid::Player);
}
2026-06-15 23:35:18 -05:00
// A solid object shadows the cell it sits on.
if let Some(id) = self.solid_object_id_at(x, y) {
2026-06-07 00:19:53 -05:00
return Some(Solid::Object(id));
}
2026-06-15 23:35:18 -05:00
// Otherwise some layer's terrain archetype may be solid (e.g. a wall).
if let Some(z) = self.solid_cell_layer(x, y) {
return Some(Solid::Cell(self.get(z, x, y).1));
2026-06-07 00:19:53 -05:00
}
None
}
2026-06-15 23:35:18 -05:00
/// Returns the index of the layer whose terrain cell at `(x, y)` is solid, if
/// any. By the one-solid-per-cell invariant there is at most one such layer.
fn solid_cell_layer(&self, x: usize, y: usize) -> Option<usize> {
(0..self.layers.len()).find(|&z| self.get(z, x, y).1.behavior().solid)
}
2026-06-07 00:19:53 -05:00
/// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it.
///
/// Convenience inverse of [`solid_at`](Board::solid_at).
/// Panics if `x` or `y` are out of bounds.
pub fn is_passable(&self, x: usize, y: usize) -> bool {
self.solid_at(x, y).is_none()
}
2026-06-21 18:27:45 -05:00
/// Returns `true` if the mover as `(x1, y1)` can enter `(x2, y2)` — i.e. it would not
/// break the rules of "one solid per cell, except for player + grab".
///
/// Convenience inverse of [`solid_at`](Board::solid_at).
/// Panics if `x` or `y` are out of bounds.
pub fn is_combinable(&self, x1: usize, y1: usize, x2: usize, y2: usize) -> bool {
// Are either out of bounds?
if !self.in_bounds((x1 as i32, y1 as i32)) || !self.in_bounds((x2 as i32, y2 as i32)) {
return false
}
// Grab the solids
let solid1 = self.solid_at(x1, y1);
let solid2 = self.solid_at(x2, y2);
// Is one cell empty?
if solid1.is_none() || solid2.is_none() { return true }
// They're both present, unwrap them:
let solid1 = solid1.unwrap();
let solid2 = solid2.unwrap();
// This is probably disallowed then, but let's check for a player coexisting with a grab:
if solid1.player() && solid2.grab(self) ||
solid2.player() && solid1.grab(self) {
return true
}
// Nope, two solids that can't coexist:
false
}
2026-06-07 00:19:53 -05:00
/// Whether the cell's single solid occupant (if any) can be pushed in `dir`.
///
/// Non-solid things are never pushable: `pushable` only matters for solids.
/// Grid archetypes may restrict the axis (see [`Pushable`]); pushable objects
/// can be shoved in any direction.
fn is_pushable(&self, x: usize, y: usize, dir: Direction) -> bool {
match self.solid_at(x, y) {
Some(Solid::Player) => true, // the player is pushable in any direction
Some(Solid::Cell(a)) => a.behavior().pushable.allows(dir),
Some(Solid::Object(id)) => self.objects[&id].pushable,
None => false,
}
}
/// Whether the chain of pushable solids starting at `(x, y)` can be shoved one
/// step in `dir` — i.e. the chain ends at a passable cell rather than the board
/// edge or a non-pushable solid.
///
/// Read-only (`&self`); pairs with [`push`](Board::push). Returns `false` when
/// `(x, y)` itself holds no pushable solid, so it doubles as the "is the cell
/// ahead shovable?" half of a "can I move here?" query.
pub fn can_push(&self, x: usize, y: usize, dir: Direction) -> bool {
let (dx, dy): (i32, i32) = dir.into();
let (mut cx, mut cy) = (x, y);
loop {
// This cell must hold a solid pushable in `dir` to advance the chain.
if !self.is_pushable(cx, cy, dir) {
return false;
}
let next = (cx as i32 + dx, cy as i32 + dy);
if !self.in_bounds(next) {
return false; // chain runs off the board
}
let (nx, ny) = (next.0 as usize, next.1 as usize);
if self.is_passable(nx, ny) {
return true; // open space at the end: the whole chain can move
}
// Next cell holds a solid too; continue (it must itself be pushable).
cx = nx;
cy = ny;
}
}
2026-06-21 01:32:47 -05:00
/// Whether the solid at `(x, y)` can be **shifted** one step in `dir`: it is
/// itself a pushable solid *and* the next cell is either empty or holds another
/// pushable solid.
///
/// Unlike [`can_push`](Board::can_push) this inspects only the single cell
/// ahead — it does **not** verify the whole chain ends in open space. It is the
/// right test for a simultaneous rotation/shift (applied via
/// [`apply_swap`](Board::apply_swap)), where a destination is occupied by
/// another pushable that is itself moving the same frame. Returns `false` if
/// `(x, y)` holds no pushable, or the cell ahead runs off the board.
2026-06-21 18:27:45 -05:00
///
/// Shifting onto the **player** is allowed only when the source is a
/// [`grab`](crate::object_def::ObjectDef::grab) thing: it isn't shoving the
/// player aside (a shift can't relocate the player, and `apply_swap` would
/// refuse to overwrite it), it's being grabbed — the player stays put and the
/// thing despawns. Any other solid treats the player as a blocker.
2026-06-21 01:32:47 -05:00
pub fn can_shift(&self, x: usize, y: usize, dir: Direction) -> bool {
// The source must hold a solid pushable in `dir`.
if !self.is_pushable(x, y, dir) {
return false;
}
let (dx, dy): (i32, i32) = dir.into();
let next = (x as i32 + dx, y as i32 + dy);
if !self.in_bounds(next) {
return false; // nothing to shift into off the board
}
let (nx, ny) = (next.0 as usize, next.1 as usize);
2026-06-21 18:27:45 -05:00
// Shifting onto the player is only legitimate for a grab thing (it gets
// grabbed, the player isn't moved); otherwise the player is a blocker.
if matches!(self.solid_at(nx, ny), Some(Solid::Player)) {
return self.grab_object_at(x, y).is_some();
}
2026-06-21 01:32:47 -05:00
// The cell ahead is acceptable if it is empty or another pushable solid.
self.is_passable(nx, ny) || self.is_pushable(nx, ny, dir)
}
2026-06-07 00:19:53 -05:00
/// Shoves the chain of pushable solids starting at `(x, y)` one step in `dir`,
/// leaving `Empty` floor behind each moved cell.
///
/// No-op when the chain can't move (it self-checks via [`can_push`](Board::can_push)),
/// so it is safe to call unconditionally.
pub fn push(&mut self, x: usize, y: usize, dir: Direction) {
if !self.can_push(x, y, dir) {
return;
}
let (dx, dy): (i32, i32) = dir.into();
// can_push guaranteed the chain ends at an in-bounds passable cell, so
// re-walk it (no bounds checks needed) and shift the far end first, which
// keeps each destination cell vacated before its occupant arrives.
let mut chain: Vec<(usize, usize)> = Vec::new();
let (mut cx, mut cy) = (x, y);
while !self.is_passable(cx, cy) {
chain.push((cx, cy));
cx = (cx as i32 + dx) as usize;
cy = (cy as i32 + dy) as usize;
}
for &(px, py) in chain.iter().rev() {
self.shift_solid(px, py, dx, dy);
}
}
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
///
2026-06-15 23:35:18 -05:00
/// A solid object is relocated (keeping its layer); otherwise the solid
/// terrain archetype (a crate) is moved within its own layer, leaving a
/// transparent cell behind so the layer beneath (e.g. floor) shows through.
2026-06-07 00:19:53 -05:00
/// The caller guarantees the destination is already clear.
fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) {
let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize);
2026-06-15 23:35:18 -05:00
// The player owns its cell, so move it before considering objects/terrain.
2026-06-07 00:19:53 -05:00
if self.player.x == x as i32 && self.player.y == y as i32 {
self.player.x = tx as i32;
self.player.y = ty as i32;
2026-06-15 23:35:18 -05:00
} else if let Some(id) = self.solid_object_id_at(x, y) {
2026-06-07 00:19:53 -05:00
let obj = self.objects.get_mut(&id).expect("id from object_id_at");
obj.x = tx;
obj.y = ty;
2026-06-15 23:35:18 -05:00
} else if let Some(z) = self.solid_cell_layer(x, y) {
let moved = *self.get(z, x, y);
*self.get_mut(z, tx, ty) = moved;
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
2026-06-07 00:19:53 -05:00
}
}
/// Returns the [`ObjectId`]s of the objects at `(x, y)`, if any.
pub fn object_ids_at(&self, x: usize, y: usize) -> Vec<ObjectId> {
self.objects
.iter()
.filter(|(_, o)| o.x == x && o.y == y)
2026-06-15 23:35:18 -05:00
.map(|(&id, _)| id)
.collect()
2026-06-07 00:19:53 -05:00
}
/// Returns a borrow of the actual object at `(x, y)` if any
pub fn solid_object_id_at(&self, x: usize, y: usize) -> Option<ObjectId> {
2026-06-15 23:35:18 -05:00
self.objects.iter().find_map(|(&id, o)| {
if o.x == x && o.y == y && o.solid {
Some(id)
} else {
None
}
})
2026-06-07 00:19:53 -05:00
}
2026-06-21 18:27:45 -05:00
/// Returns the [`ObjectId`] of a solid, **grab**bable object at `(x, y)`, if any.
///
/// Used by [`GameState::try_move`](crate::game::GameState::try_move) to detect
/// the player walking onto a grab thing (e.g. a gem): the move isn't blocked,
/// the object's `grab()` hook fires instead.
pub fn grab_object_at(&self, x: usize, y: usize) -> Option<ObjectId> {
self.objects.iter().find_map(|(&id, o)| {
if o.x == x && o.y == y && o.solid && o.grab {
Some(id)
} else {
None
}
})
}
/// Returns the [`ObjectId`] of a **grab** object that a push at `(x, y)` in
/// `dir` would shove into the player, if any.
///
/// Walks the pushable chain from `(x, y)`; if a chain cell holds a grab object
/// whose immediate forward neighbour is the player, that object is returned.
/// This is the "grab thing pushed into the player" case: rather than sliding
/// the player along, the caller grabs the thing (see
/// [`GameState`](crate::game::GameState)). Returns `None` for an ordinary push.
pub fn pushed_grab_into_player(&self, x: usize, y: usize, dir: Direction) -> Option<ObjectId> {
let (dx, dy): (i32, i32) = dir.into();
let (mut cx, mut cy) = (x, y);
// Advance through the contiguous run of pushable solids.
while self.is_pushable(cx, cy, dir) {
let next = (cx as i32 + dx, cy as i32 + dy);
if !self.in_bounds(next) {
return None;
}
let (nx, ny) = (next.0 as usize, next.1 as usize);
// If the next cell is the player and this cell is a grab object, the
// push would drive that grab thing into the player: grab it instead.
if matches!(self.solid_at(nx, ny), Some(Solid::Player)) {
return self.grab_object_at(cx, cy);
}
cx = nx;
cy = ny;
}
None
}
2026-06-07 00:19:53 -05:00
/// Inserts `object`, assigning it the next free [`ObjectId`], and returns that id.
///
/// Ids start at 1 and increase monotonically; an id is never reused, so it
/// stays a valid handle to this object for the board's lifetime.
2026-06-07 01:26:18 -05:00
pub fn add_object(&mut self, mut object: ObjectDef) -> ObjectId {
2026-06-07 00:19:53 -05:00
let id = self.next_object_id;
self.next_object_id += 1;
2026-06-07 01:26:18 -05:00
object.id = id;
2026-06-07 00:19:53 -05:00
self.objects.insert(id, object);
id
}
2026-06-20 17:53:47 -05:00
2026-06-21 01:32:47 -05:00
/// Removes the object with `id`, returning its [`ObjectDef`] if it existed.
///
/// This only touches the board's `objects` map. A live [`ScriptHost`] built
/// before the removal keeps a stale `ObjectRuntime` for the gone object; its
/// subsequent host-fn calls resolve to a missing id and become no-ops, so the
/// removal is benign even mid-game (see CLAUDE.md's runtime spawn/destroy note).
///
/// [`ScriptHost`]: crate::script::ScriptHost
pub fn remove_object(&mut self, id: ObjectId) -> Option<ObjectDef> {
self.objects.remove(&id)
}
2026-06-20 17:53:47 -05:00
/// 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 cell's terrain.
/// - **Erase** (`arch == Empty`): removes the cell's terrain *and* every object in
/// it, leaving the floor (a visible `Empty` cell on a lower layer) in place.
///
/// Terrain is written to the cell's existing terrain layer (the single non-`Empty`
/// archetype across layers, if any) or else the top layer; a vacated terrain cell
/// becomes a transparent `Empty` so a lower floor shows through. Panics if `(x, y)`
/// is out of bounds.
pub fn place_archetype(&mut self, x: usize, y: usize, arch: Archetype, glyph: Glyph) {
if arch == Archetype::Empty {
// Erase: drop every object in the cell and clear its terrain (keep floor).
for id in self.object_ids_at(x, y) {
self.objects.remove(&id);
}
if let Some(z) = self.terrain_layer_at(x, y) {
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
}
return;
}
// Placing solid terrain: a solid object can't share the cell, so drop it.
if let Some(id) = self.solid_object_id_at(x, y) {
self.objects.remove(&id);
}
// Reuse the existing terrain layer if the cell already has terrain, else the
// top layer (so the new wall draws above any floor on a lower layer).
let z = self.terrain_layer_at(x, y).unwrap_or(self.layers.len() - 1);
*self.get_mut(z, x, y) = (glyph, arch);
}
2026-06-21 01:32:47 -05:00
/// Replaces every terrain cell whose archetype is script-backed (e.g. a
/// `Spinner` or `Pusher`) with the scripted object it expands to — the same
/// transformation the map loader applies in [`layer::build_layer`](crate::layer),
/// but run against a live, already-built board.
///
/// The editor stamps these archetypes as plain terrain cells (via
/// [`place_archetype`](Board::place_archetype)); they only come alive once
/// expanded into objects carrying their embedded script + `BUILTIN_*` tag. Call
/// this before running a board assembled in memory (e.g. entering a playtest), so
/// editor-placed machines actually run. A save→reload round-trip expands them via
/// the normal load path, so this is only needed for the in-memory path. Cells
/// already loaded as objects are untouched, so it is safe to call more than once.
pub fn expand_builtin_archetypes(&mut self) {
use crate::builtin_scripts::{archetype_script, builtin_tag};
// Collect first: the loop below mutates both layers and the object map.
let mut found: Vec<(usize, usize, usize, Glyph, Archetype)> = Vec::new();
for z in 0..self.layers.len() {
for y in 0..self.height {
for x in 0..self.width {
let (glyph, arch) = *self.get(z, x, y);
if archetype_script(arch).is_some() {
found.push((z, x, y, glyph, arch));
}
}
}
}
for (z, x, y, glyph, arch) in found {
// Vacate the terrain cell (revealing any floor beneath), then spawn the
// object — mirroring `resolve_entry`'s object template.
*self.get_mut(z, x, y) = (Glyph::transparent(), Archetype::Empty);
let b = arch.behavior();
let mut obj = ObjectDef::new(x, y);
obj.z = z;
obj.glyph = glyph;
obj.solid = b.solid;
obj.opaque = b.opaque;
2026-06-21 18:27:45 -05:00
// Carry the archetype's pushability/grab onto the object (pushers and
// spinners are Pushable::No, so they stay unpushable; gems are pushable
// and grabbable).
obj.pushable = b.pushable != crate::utils::Pushable::No;
obj.grab = b.grab;
2026-06-21 01:32:47 -05:00
obj.builtin_script = archetype_script(arch);
2026-06-21 18:27:45 -05:00
obj.script_name = archetype_script_key(arch).map(|s| s.to_owned());
2026-06-21 01:32:47 -05:00
obj.tags.insert(builtin_tag(arch));
self.add_object(obj);
}
}
/// Applies a batch of one-way solid moves **simultaneously**, returning any
/// nonfatal error lines (out-of-bounds entries / a write blocked by the player).
///
/// Each tuple is `(src_x, src_y, dst_x, dst_y)`: the solid occupant of `(src_x,
/// src_y)` — the player, a solid object, or a terrain crate/wall — moves to
/// `(dst_x, dst_y)`. A source with no solid moves an "empty", which **removes**
/// whatever solid was at the destination. Every source is read before any
/// destination is written, so cyclic permutations and two-cell swaps resolve
/// correctly (e.g. `[a→b],[b→a]` swaps `a` and `b`).
///
/// Displacement rules: a destination's prior solid that isn't itself being moved
/// is removed (terrain cleared; a scripted object despawned via
/// [`remove_object`](Board::remove_object)). The **player is never destroyed** —
/// a write that would overwrite the player without relocating it is skipped and
/// logged (the player wins its cell, per the one-solid-per-cell invariant).
2026-06-21 18:27:45 -05:00
///
/// **Grab:** a [`grab`](crate::object_def::ObjectDef::grab) object whose
/// destination is the player isn't written onto the player — it is left where it
/// is and its id is returned in the second tuple element so the caller can fire
/// its `grab()` hook (it gets grabbed, the player isn't moved). Because a
/// grabbed object stays put (and its `grab()` may not despawn it), it can end up
/// sharing a cell with a solid that moved in; a final sweep over the swapped
/// cells resolves any such **overlap** — it keeps one solid (preferring a
/// grabbed object so its `grab()` can still fire), deletes the rest (never the
/// player), and logs an error per deletion.
pub fn apply_swap(&mut self, pairs: &[(i32, i32, i32, i32)]) -> (Vec<LogLine>, Vec<ObjectId>) {
2026-06-21 01:32:47 -05:00
let mut errors = Vec::new();
2026-06-21 18:27:45 -05:00
// Grab objects whose destination was the player: left in place here, their
// grab() hooks fired by the caller (GameState).
let mut grabbed: Vec<ObjectId> = Vec::new();
2026-06-21 01:32:47 -05:00
// 1. Validate: keep only entries whose source and destination are in bounds.
let mut valid: Vec<((i32, i32), (i32, i32))> = Vec::new();
for &(sx, sy, dx, dy) in pairs {
if !self.in_bounds((sx, sy)) || !self.in_bounds((dx, dy)) {
errors.push(LogLine::error(format!(
"swap: out-of-bounds entry ({sx},{sy})->({dx},{dy})"
)));
continue;
}
valid.push(((sx, sy), (dx, dy)));
}
// 2. Snapshot the solid at each unique source (read phase). Reading every
// source before any write is what lets cycles/swaps resolve.
let mut snapshots: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
for &(src, _) in &valid {
snapshots
.entry(src)
.or_insert_with(|| self.snapshot_solid(src));
}
// 3. Compute the final occupant of every affected cell. A source that is not
// anyone's destination is vacated (Empty); each entry writes its source's
// snapshot into its destination (a later entry wins a repeated destination).
let dsts: HashSet<(i32, i32)> = valid.iter().map(|&(_, d)| d).collect();
let mut final_state: HashMap<(i32, i32), SolidSnapshot> = HashMap::new();
for &(src, _) in &valid {
if !dsts.contains(&src) {
final_state.insert(src, SolidSnapshot::Empty);
}
}
for &(src, dst) in &valid {
final_state.insert(dst, snapshots[&src].clone());
}
// The player's final cell: where a Player snapshot is installed, else its
// current cell (it stays put). Used to protect the player from being
// overwritten — computed up front so it's stable across the write loop.
let player_final = final_state
.iter()
.find(|(_, s)| matches!(s, SolidSnapshot::Player))
.map(|(&c, _)| c)
.unwrap_or((self.player.x, self.player.y));
// 4a. Clear each affected cell's current occupant, despawning any object that
// doesn't survive (isn't reused in final_state). The player and surviving
// objects keep their entity and are repositioned by the install step.
let survivors: HashSet<ObjectId> = final_state
.values()
.filter_map(|s| match s {
SolidSnapshot::Object(id) => Some(*id),
_ => None,
})
.collect();
let affected: HashSet<(i32, i32)> = snapshots
.keys()
.copied()
.chain(final_state.keys().copied())
.collect();
for &(cx, cy) in &affected {
let (ux, uy) = (cx as usize, cy as usize);
match self.solid_at(ux, uy) {
Some(Solid::Object(id)) if !survivors.contains(&id) => {
self.remove_object(id);
}
Some(Solid::Cell(_)) => {
if let Some(z) = self.solid_cell_layer(ux, uy) {
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty);
}
}
_ => {}
}
}
// 4b. Install each cell's computed occupant.
for (&(cx, cy), snap) in &final_state {
let (ux, uy) = (cx as usize, cy as usize);
// The player wins its cell: never overwrite player_final with anything
// other than the player itself.
if (cx, cy) == player_final && !matches!(snap, SolidSnapshot::Player) {
2026-06-21 18:27:45 -05:00
// A grab object shoved onto the player is grabbed, not blocked: leave
// it where it is (don't install onto the player) and report it so the
// caller fires its grab() hook. The overlap sweep below cleans up if
// its source cell is now also someone else's destination.
if let SolidSnapshot::Object(id) = snap
&& self.objects.get(id).is_some_and(|o| o.grab)
{
grabbed.push(*id);
continue;
}
2026-06-21 01:32:47 -05:00
errors.push(LogLine::error(format!(
"swap: cannot overwrite the player at ({cx},{cy})"
)));
continue;
}
match snap {
SolidSnapshot::Empty => {} // already cleared
SolidSnapshot::Terrain { z, glyph, arch } => {
*self.get_mut(*z, ux, uy) = (*glyph, *arch);
}
SolidSnapshot::Object(id) => {
if let Some(obj) = self.objects.get_mut(id) {
obj.x = ux;
obj.y = uy;
}
}
SolidSnapshot::Player => {
self.player.x = cx;
self.player.y = cy;
}
}
}
2026-06-21 18:27:45 -05:00
// 5. Overlap sweep: a grabbed object left in place (above) may now share a
// cell with a solid that moved in. For any swapped cell holding more than
// one solid, keep a single occupant — a grabbed object if present (so its
// grab() can still fire), otherwise whatever remains — and delete the rest
// (never the player). Logs an error per deletion.
for &(cx, cy) in &affected {
let (ux, uy) = (cx as usize, cy as usize);
let player_here = self.player.x == cx && self.player.y == cy;
// Solid objects on this cell, with grabbed ones first so we keep one.
let mut objs: Vec<ObjectId> = self
.objects
.iter()
.filter(|(_, o)| o.x == ux && o.y == uy && o.solid)
.map(|(&id, _)| id)
.collect();
objs.sort_by_key(|id| !grabbed.contains(id));
let has_terrain = self.solid_cell_layer(ux, uy).is_some();
// How many solids share the cell (player + solid objects + solid terrain).
let total = player_here as usize + objs.len() + has_terrain as usize;
if total <= 1 {
continue;
}
errors.push(LogLine::error(format!(
"swap: {total} solids overlap at ({cx},{cy}); deleting extras"
)));
// Pick the survivor (priority: player > grabbed/first object > terrain)
// and delete every other solid. The player is only ever a survivor, so
// it is never deleted. `keep_obj` is the object we keep, if any.
let keep_obj = (!player_here).then(|| objs.first().copied()).flatten();
for id in &objs {
if Some(*id) != keep_obj {
self.remove_object(*id);
}
}
// Clear terrain unless it's the sole survivor (no player, no kept object).
if has_terrain
&& (player_here || keep_obj.is_some())
&& let Some(z) = self.solid_cell_layer(ux, uy)
{
*self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty);
}
}
(errors, grabbed)
2026-06-21 01:32:47 -05:00
}
/// Reads the solid occupant of `(x, y)` into a [`SolidSnapshot`] (for
/// [`apply_swap`](Board::apply_swap)). Coordinates must be in bounds.
fn snapshot_solid(&self, (x, y): (i32, i32)) -> SolidSnapshot {
let (ux, uy) = (x as usize, y as usize);
match self.solid_at(ux, uy) {
Some(Solid::Player) => SolidSnapshot::Player,
Some(Solid::Object(id)) => SolidSnapshot::Object(id),
Some(Solid::Cell(arch)) => {
let z = self
.solid_cell_layer(ux, uy)
.expect("a solid terrain cell has a layer");
SolidSnapshot::Terrain {
z,
glyph: self.get(z, ux, uy).0,
arch,
}
}
None => SolidSnapshot::Empty,
}
}
2026-06-20 17:53:47 -05:00
/// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty`
/// (the cell's single terrain archetype, if any). By the one-solid-per-cell
/// invariant there is at most one such layer.
fn terrain_layer_at(&self, x: usize, y: usize) -> Option<usize> {
(0..self.layers.len()).find(|&z| self.get(z, x, y).1 != Archetype::Empty)
}
2026-06-07 00:33:16 -05:00
}
#[cfg(test)]
pub(crate) mod tests {
2026-06-15 23:35:18 -05:00
use super::Board;
2026-06-21 01:32:47 -05:00
use crate::archetype::{Archetype, SpinDirection};
2026-06-07 00:33:16 -05:00
use crate::glyph::Glyph;
2026-06-15 23:35:18 -05:00
use crate::layer::Layer;
2026-06-07 00:33:16 -05:00
use crate::object_def::ObjectDef;
2026-06-08 22:15:44 -05:00
use crate::utils::Direction;
2026-06-07 00:33:16 -05:00
use crate::utils::{ObjectId, Player, Solid};
2026-06-15 23:35:18 -05:00
use color::Rgba8;
use std::collections::{BTreeMap, HashMap};
2026-06-07 00:33:16 -05:00
2026-06-15 23:35:18 -05:00
/// Builds an all-empty `w×h` single-layer board with the given player position
/// and objects (all on layer 0). Assigns sequential ids (1..=n) to objects.
///
/// The single layer is fully transparent, so terrain stamped via [`crate_at`]
/// etc. always lands on the board's top layer. Use [`add_floor`] to slip a
/// visible floor layer underneath.
2026-06-07 00:33:16 -05:00
pub(crate) fn open_board(
w: usize,
h: usize,
player: (i32, i32),
objects: Vec<ObjectDef>,
) -> Board {
let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
let mut next_object_id: ObjectId = 1;
2026-06-07 01:26:18 -05:00
for mut o in objects {
o.id = next_object_id;
2026-06-07 00:33:16 -05:00
object_map.insert(next_object_id, o);
next_object_id += 1;
}
Board {
name: "test".into(),
width: w,
height: h,
2026-06-15 23:35:18 -05:00
layers: vec![Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty); w * h],
}],
player: Player {
x: player.0,
y: player.1,
},
2026-06-07 00:33:16 -05:00
objects: object_map,
next_object_id,
portals: Vec::new(),
board_script_name: None,
load_errors: Vec::new(),
2026-06-13 17:58:04 -05:00
registry: HashMap::new(),
2026-06-07 00:33:16 -05:00
}
}
2026-06-15 23:35:18 -05:00
/// Inserts a visible floor layer (filled with `glyph`) below everything,
/// bumping existing terrain and objects up one layer.
pub(crate) fn add_floor(board: &mut Board, glyph: Glyph) {
let count = board.width * board.height;
board.layers.insert(
0,
Layer {
cells: vec![(glyph, Archetype::Empty); count],
},
);
for o in board.objects.values_mut() {
o.z += 1;
}
}
/// The index of the board's top (terrain) layer, where stamps are written.
fn top(board: &Board) -> usize {
board.layers.len() - 1
}
/// Stamps a crate cell onto the board's top layer.
2026-06-07 00:33:16 -05:00
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
2026-06-15 23:35:18 -05:00
let z = top(board);
*board.get_mut(z, x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
2026-06-07 00:33:16 -05:00
}
2026-06-15 23:35:18 -05:00
/// Stamps a wall cell onto the board's top layer.
2026-06-07 00:33:16 -05:00
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
2026-06-15 23:35:18 -05:00
let z = top(board);
*board.get_mut(z, x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
2026-06-07 00:33:16 -05:00
}
2026-06-15 23:35:18 -05:00
/// Stamps an arbitrary archetype cell onto the board's top layer.
2026-06-07 00:33:16 -05:00
pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
2026-06-15 23:35:18 -05:00
let z = top(board);
*board.get_mut(z, x, y) = (arch.default_glyph(), arch);
2026-06-07 00:33:16 -05:00
}
#[test]
fn solid_at_reports_wall_object_and_empty() {
2026-06-13 01:25:58 -05:00
let mut board = open_board(4, 1, (3, 0), vec![]);
2026-06-15 23:35:18 -05:00
wall_at(&mut board, 1, 0);
2026-06-07 00:33:16 -05:00
board.add_object(ObjectDef::new(2, 0));
assert!(board.solid_at(0, 0).is_none());
assert!(board.is_passable(0, 0));
match board.solid_at(1, 0) {
Some(Solid::Cell(Archetype::Wall)) => {}
other => panic!("expected Solid::Cell(Wall), got {:?}", other.is_some()),
}
assert!(!board.is_passable(1, 0));
match board.solid_at(2, 0) {
2026-06-15 23:35:18 -05:00
Some(Solid::Object(id)) => {
assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0))
}
2026-06-07 00:33:16 -05:00
_ => panic!("expected Solid::Object"),
}
assert!(!board.is_passable(2, 0));
}
2026-06-21 18:27:45 -05:00
#[test]
fn grab_helpers_detect_a_gem() {
// A grabbable gem object at (1,0); the player at (2,0).
let mut gem = ObjectDef::new(1, 0);
gem.grab = true;
gem.pushable = true;
let board = open_board(3, 1, (2, 0), vec![gem]);
// grab_object_at finds the gem on its own cell, nowhere else.
assert_eq!(board.grab_object_at(1, 0), Some(1));
assert_eq!(board.grab_object_at(0, 0), None);
// Pushing the gem east shoves it into the player → reported as a grab.
assert_eq!(
board.pushed_grab_into_player(1, 0, Direction::East),
Some(1)
);
// Pushing it west (away from the player) is an ordinary push.
assert_eq!(board.pushed_grab_into_player(1, 0, Direction::West), None);
}
2026-06-07 00:33:16 -05:00
#[test]
fn non_solid_object_does_not_block() {
let mut obj = ObjectDef::new(1, 0);
obj.solid = false;
2026-06-13 01:25:58 -05:00
let board = open_board(3, 1, (0, 0), vec![obj]);
2026-06-07 00:33:16 -05:00
assert!(board.solid_at(1, 0).is_none());
assert!(board.is_passable(1, 0));
}
#[test]
fn solid_at_reports_player() {
2026-06-13 01:25:58 -05:00
let board = open_board(3, 1, (1, 0), vec![]);
2026-06-07 00:33:16 -05:00
match board.solid_at(1, 0) {
Some(Solid::Player) => {}
_ => panic!("expected Solid::Player at the player's cell"),
}
assert!(!board.is_passable(1, 0));
}
#[test]
fn in_bounds_checks_grid_boundaries() {
2026-06-13 01:25:58 -05:00
let board = open_board(3, 2, (0, 0), vec![]);
2026-06-07 00:33:16 -05:00
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 can_push_is_read_only_and_correct() {
2026-06-13 01:25:58 -05:00
let mut board = open_board(3, 1, (0, 0), vec![]);
2026-06-07 00:33:16 -05:00
crate_at(&mut board, 1, 0);
assert!(board.can_push(1, 0, Direction::East));
2026-06-15 23:35:18 -05:00
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate); // no mutation
assert_eq!(board.get(0, 2, 0).1, Archetype::Empty);
2026-06-07 00:33:16 -05:00
2026-06-13 01:25:58 -05:00
let mut board = open_board(3, 1, (0, 0), vec![]);
2026-06-07 00:33:16 -05:00
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
assert!(!board.can_push(1, 0, Direction::East));
}
2026-06-21 01:32:47 -05:00
#[test]
fn can_shift_only_checks_the_cell_ahead() {
// Source must be pushable.
let mut board = open_board(4, 1, (3, 0), vec![]);
assert!(!board.can_shift(0, 0, Direction::East)); // empty source
// Crate with open space ahead: shiftable.
crate_at(&mut board, 0, 0);
assert!(board.can_shift(0, 0, Direction::East));
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // read-only
// Crate with another pushable crate ahead: still shiftable (unlike can_push,
// which would follow the chain to the wall and fail).
let mut board = open_board(4, 1, (3, 0), vec![]);
crate_at(&mut board, 0, 0);
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
assert!(board.can_shift(0, 0, Direction::East));
assert!(!board.can_push(0, 0, Direction::East));
// Crate with a non-pushable wall ahead: not shiftable.
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 0, 0);
wall_at(&mut board, 1, 0);
assert!(!board.can_shift(0, 0, Direction::East));
// Crate at the board edge facing off-board: not shiftable.
let mut board = open_board(2, 1, (0, 0), vec![]);
crate_at(&mut board, 1, 0);
assert!(!board.can_shift(1, 0, Direction::East));
}
2026-06-21 18:27:45 -05:00
#[test]
fn can_shift_into_player_only_for_a_grab_thing() {
// A grab gem at (0,0) may shift east onto the player at (1,0): it gets
// grabbed rather than blocked.
let mut gem = ObjectDef::new(0, 0);
gem.grab = true;
gem.pushable = true;
let board = open_board(2, 1, (1, 0), vec![gem]);
assert!(board.can_shift(0, 0, Direction::East));
// A plain crate may not shift onto the player — the player blocks it.
let mut board = open_board(2, 1, (1, 0), vec![]);
crate_at(&mut board, 0, 0);
assert!(!board.can_shift(0, 0, Direction::East));
}
2026-06-07 00:33:16 -05:00
#[test]
fn push_into_player_pushes_player() {
// Crate shoved east into the player slides the player along into open space.
2026-06-13 01:25:58 -05:00
let mut board = open_board(4, 1, (2, 0), vec![]);
2026-06-07 00:33:16 -05:00
crate_at(&mut board, 1, 0);
assert!(board.can_push(1, 0, Direction::East));
board.push(1, 0, Direction::East);
2026-06-15 23:35:18 -05:00
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
2026-06-07 00:33:16 -05:00
assert_eq!((board.player.x, board.player.y), (3, 0));
}
#[test]
fn push_into_player_blocked_by_wall() {
// Player backed against a wall: push has nowhere to go, nothing moves.
2026-06-13 01:25:58 -05:00
let mut board = open_board(4, 1, (2, 0), vec![]);
2026-06-07 00:33:16 -05:00
crate_at(&mut board, 1, 0);
wall_at(&mut board, 3, 0);
assert!(!board.can_push(1, 0, Direction::East));
board.push(1, 0, Direction::East); // no-op
2026-06-15 23:35:18 -05:00
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
2026-06-07 00:33:16 -05:00
assert_eq!((board.player.x, board.player.y), (2, 0));
}
#[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.
2026-06-13 01:25:58 -05:00
let mut board = open_board(3, 1, (2, 0), vec![]);
2026-06-07 00:33:16 -05:00
let floor_glyph = Glyph {
tile: '.' as u32,
2026-06-15 23:35:18 -05:00
fg: Rgba8 {
r: 10,
g: 20,
b: 30,
a: 255,
},
bg: Rgba8 {
r: 1,
g: 2,
b: 3,
a: 255,
},
2026-06-07 00:33:16 -05:00
};
2026-06-15 23:35:18 -05:00
// Floor on a lower layer, a wall on the top (terrain) layer at (0,0).
add_floor(&mut board, floor_glyph);
2026-06-07 00:33:16 -05:00
wall_at(&mut board, 0, 0);
2026-06-15 23:35:18 -05:00
// The wall (solid) draws over the floor; the empty cell reveals the floor.
2026-06-07 00:33:16 -05:00
assert_eq!(board.glyph_at(0, 0), Archetype::Wall.default_glyph());
assert_eq!(board.glyph_at(1, 0), floor_glyph);
}
2026-06-15 23:35:18 -05:00
#[test]
fn glyph_at_draws_higher_layer_over_lower() {
// A non-solid object on an upper layer renders above a wall on a lower one.
let mut board = open_board(2, 1, (1, 0), vec![]);
wall_at(&mut board, 0, 0); // wall on layer 0
// Add an upper layer holding a visible, non-solid object at (0,0).
board.layers.push(Layer {
cells: vec![(Glyph::transparent(), Archetype::Empty); 2],
});
let mut obj = ObjectDef::new(0, 0);
obj.z = 1;
obj.solid = false;
obj.glyph = Glyph {
tile: '*' as u32,
..Glyph::transparent()
};
board.add_object(obj);
assert_eq!(board.glyph_at(0, 0).tile, '*' as u32);
}
2026-06-20 17:53:47 -05:00
#[test]
fn place_wall_keeps_floor_and_removes_solid_object() {
// Floor on layer 0, terrain layer on top; a solid object sits at (1,0).
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
let floor = Glyph {
tile: '.' as u32,
..Glyph::transparent()
};
add_floor(&mut board, floor);
let wall = Archetype::Wall.default_glyph();
board.place_archetype(1, 0, Archetype::Wall, wall);
// The wall landed on the terrain (top) layer; the floor below is untouched.
assert_eq!(board.get(1, 1, 0), &(wall, Archetype::Wall));
assert_eq!(board.get(0, 1, 0).0, floor);
// The solid object that was there is gone.
assert!(board.object_ids_at(1, 0).is_empty());
assert_eq!(board.glyph_at(1, 0), wall);
}
#[test]
fn place_wall_overwrites_existing_terrain_in_place() {
// A crate already occupies the top layer at (1,0).
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 1, 0);
let wall = Archetype::Wall.default_glyph();
board.place_archetype(1, 0, Archetype::Wall, wall);
assert_eq!(board.get(0, 1, 0), &(wall, Archetype::Wall));
}
#[test]
fn erase_removes_terrain_and_objects_but_keeps_floor() {
// Floor, a wall on the terrain layer, and a (non-solid) object all at (1,0).
let mut obj = ObjectDef::new(1, 0);
obj.solid = false;
let mut board = open_board(3, 1, (2, 0), vec![obj]);
let floor = Glyph {
tile: '.' as u32,
..Glyph::transparent()
};
add_floor(&mut board, floor);
wall_at(&mut board, 1, 0);
board.place_archetype(1, 0, Archetype::Empty, Glyph::transparent());
// Terrain cleared to transparent Empty; object removed; floor still there.
assert_eq!(
board.get(1, 1, 0),
&(Glyph::transparent(), Archetype::Empty)
);
assert!(board.object_ids_at(1, 0).is_empty());
assert_eq!(board.get(0, 1, 0).0, floor);
assert_eq!(board.glyph_at(1, 0), floor);
}
2026-06-07 00:33:16 -05:00
#[test]
fn fresh_board_is_valid_and_reports_errors() {
2026-06-13 01:25:58 -05:00
let mut board = open_board(1, 1, (0, 0), vec![]);
2026-06-07 00:33:16 -05:00
assert!(board.is_valid());
board.report_error("something went wrong");
assert!(!board.is_valid());
assert_eq!(board.load_errors.len(), 1);
}
2026-06-21 01:32:47 -05:00
#[test]
fn expand_builtin_archetypes_replaces_a_spinner_cell_with_an_object() {
let mut board = open_board(3, 1, (2, 0), vec![]);
stamp(
&mut board,
0,
0,
Archetype::Spinner(SpinDirection::Clockwise),
);
board.expand_builtin_archetypes();
// The terrain cell is vacated and a scripted object takes its place.
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
let obj = board.objects.values().next().expect("spinner object");
assert_eq!((obj.x, obj.y), (0, 0));
assert!(obj.solid);
assert!(obj.builtin_script.is_some());
assert!(obj.tags.contains("BUILTIN_spinner_cw"));
// Idempotent: nothing left to expand on a second pass.
board.expand_builtin_archetypes();
assert_eq!(board.objects.len(), 1);
}
#[test]
fn remove_object_deletes_from_map() {
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(0, 0)]);
assert!(board.solid_object_id_at(0, 0).is_some());
let removed = board.remove_object(1);
assert!(removed.is_some());
assert!(board.solid_object_id_at(0, 0).is_none());
assert!(board.remove_object(1).is_none()); // already gone
}
#[test]
fn apply_swap_swaps_two_terrain_cells() {
// A crate and a wall trade places in one batch (read-all then write-all).
let mut board = open_board(3, 1, (1, 0), vec![]);
crate_at(&mut board, 0, 0);
wall_at(&mut board, 2, 0);
2026-06-21 18:27:45 -05:00
let (errs, _) = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
2026-06-21 01:32:47 -05:00
assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Wall);
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
}
#[test]
fn apply_swap_cycle_propagates_empty() {
// The spec example: move a->b and b->c with a empty ⇒ a empty, b empty,
// c holds what b held (the empty written into b doesn't block b->c).
let mut board = open_board(4, 1, (3, 0), vec![]);
// a = (0,0) empty, b = (1,0) crate, c = (2,0) wall.
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
2026-06-21 18:27:45 -05:00
let (errs, _) = board.apply_swap(&[(0, 0, 1, 0), (1, 0, 2, 0)]);
2026-06-21 01:32:47 -05:00
assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 1, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 2, 0).1, Archetype::Crate);
}
#[test]
fn apply_swap_empty_onto_object_removes_it() {
// Moving an empty source onto an object despawns the object.
let mut board = open_board(3, 1, (2, 0), vec![ObjectDef::new(1, 0)]);
2026-06-21 18:27:45 -05:00
let (errs, _) = board.apply_swap(&[(0, 0, 1, 0)]);
2026-06-21 01:32:47 -05:00
assert!(errs.is_empty());
assert!(board.solid_object_id_at(1, 0).is_none());
assert!(board.objects.is_empty());
}
#[test]
fn apply_swap_terrain_onto_terrain_removes_displaced() {
// Moving a crate onto a wall clears the source and removes the wall.
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 0, 0);
wall_at(&mut board, 1, 0);
2026-06-21 18:27:45 -05:00
let (errs, _) = board.apply_swap(&[(0, 0, 1, 0)]);
2026-06-21 01:32:47 -05:00
assert!(errs.is_empty());
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
assert_eq!(board.get(0, 1, 0).1, Archetype::Crate);
}
#[test]
fn apply_swap_moves_object_and_player() {
// An object and the player relocate (swap places) in one batch.
let mut board = open_board(3, 1, (0, 0), vec![ObjectDef::new(2, 0)]);
2026-06-21 18:27:45 -05:00
let (errs, _) = board.apply_swap(&[(0, 0, 2, 0), (2, 0, 0, 0)]);
2026-06-21 01:32:47 -05:00
assert!(errs.is_empty());
assert_eq!((board.player.x, board.player.y), (2, 0));
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
}
#[test]
fn apply_swap_out_of_bounds_skips_and_logs() {
let mut board = open_board(3, 1, (2, 0), vec![]);
crate_at(&mut board, 0, 0);
2026-06-21 18:27:45 -05:00
let (errs, _) = board.apply_swap(&[(0, 0, 9, 0)]);
2026-06-21 01:32:47 -05:00
assert_eq!(errs.len(), 1);
assert_eq!(board.get(0, 0, 0).1, Archetype::Crate); // unchanged
}
#[test]
fn apply_swap_will_not_overwrite_player() {
// A crate moved onto the (non-relocating) player is rejected and logged.
let mut board = open_board(3, 1, (1, 0), vec![]);
crate_at(&mut board, 0, 0);
2026-06-21 18:27:45 -05:00
let (errs, _) = board.apply_swap(&[(0, 0, 1, 0)]);
2026-06-21 01:32:47 -05:00
assert_eq!(errs.len(), 1);
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // source still vacated
}
2026-06-21 18:27:45 -05:00
#[test]
fn apply_swap_grab_onto_player_is_reported_not_moved() {
// A grab gem swapped onto the player is reported (for its grab() hook) and
// left at its source rather than overwriting / sliding the player.
let mut gem = ObjectDef::new(0, 0);
gem.grab = true;
gem.pushable = true;
let mut board = open_board(2, 1, (1, 0), vec![gem]);
let (errs, grabbed) = board.apply_swap(&[(0, 0, 1, 0)]);
assert!(errs.is_empty());
assert_eq!(grabbed, vec![1]);
assert_eq!((board.player.x, board.player.y), (1, 0)); // player kept its cell
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // gem stayed
}
#[test]
fn apply_swap_overlap_after_grab_deletes_the_other_solid() {
// gem (0,0)→player (1,0) [grabbed, stays at (0,0)] while a crate (2,0)→(0,0)
// moves into the gem's cell. The sweep keeps the grabbed gem and deletes the
// crate, logging the overlap.
let mut gem = ObjectDef::new(0, 0);
gem.grab = true;
gem.pushable = true;
let mut board = open_board(3, 1, (1, 0), vec![gem]);
crate_at(&mut board, 2, 0);
let (errs, grabbed) = board.apply_swap(&[(0, 0, 1, 0), (2, 0, 0, 0)]);
assert_eq!(grabbed, vec![1]);
assert_eq!(errs.len(), 1); // the overlap was logged
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); // gem kept
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty); // crate deleted
assert_eq!((board.player.x, board.player.y), (1, 0)); // player untouched
}
2026-06-15 23:35:18 -05:00
}