2026-06-07 00:19:53 -05:00
|
|
|
|
use crate::archetype::Archetype;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
use crate::floor::Floor;
|
2026-06-07 00:19:53 -05:00
|
|
|
|
use crate::glyph::Glyph;
|
|
|
|
|
|
use crate::log::LogLine;
|
|
|
|
|
|
use crate::object_def::ObjectDef;
|
2026-06-08 22:15:44 -05:00
|
|
|
|
use crate::utils::Direction;
|
2026-06-25 00:04:42 -05:00
|
|
|
|
use crate::utils::{Behavior, ObjectId, PlayerPos, PortalDef, Pushable, RegistryValue, Solid};
|
2026-06-21 01:32:47 -05:00
|
|
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// A non-solid `(glyph, archetype)` placed at a board coordinate, **outside** the
|
|
|
|
|
|
/// main grid, drawn only when the grid cell at `(x, y)` is empty.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Decorations exist so a single file format can serve both world files and save
|
|
|
|
|
|
/// files: at authoring time every cell holds at most one thing, but during play a
|
|
|
|
|
|
/// runtime solid can end up sharing a cell with a non-solid that was already there.
|
|
|
|
|
|
/// The non-solid is recorded here (the grid keeps the solid). The editor does not
|
|
|
|
|
|
/// place decorations; they are a save/runtime concern. A decoration's archetype is
|
|
|
|
|
|
/// always non-solid (a solid one is rejected at load).
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
|
pub struct Decoration {
|
|
|
|
|
|
/// Column (0-indexed).
|
|
|
|
|
|
pub x: usize,
|
|
|
|
|
|
/// Row (0-indexed).
|
|
|
|
|
|
pub y: usize,
|
|
|
|
|
|
/// The decoration's visual.
|
|
|
|
|
|
pub glyph: Glyph,
|
|
|
|
|
|
/// The decoration's (non-solid) archetype.
|
|
|
|
|
|
pub archetype: Archetype,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-07-10 23:16:28 -05:00
|
|
|
|
/// The single row-major grid of `(Glyph, Archetype)` cells (`width * height`),
|
|
|
|
|
|
/// holding every solid and most non-solids. A transparent cell (glyph tile 0)
|
|
|
|
|
|
/// draws nothing, revealing a [`decoration`](Board::decorations) or the
|
|
|
|
|
|
/// [`floor`](Board::floor) beneath. Access a cell with [`Board::get`]/
|
|
|
|
|
|
/// [`Board::get_mut`] by `(x, y)`.
|
|
|
|
|
|
pub(crate) grid: Vec<(Glyph, Archetype)>,
|
|
|
|
|
|
/// The board's cosmetic floor (blank / one fixed glyph / a biome), drawn beneath
|
|
|
|
|
|
/// everything. Replaces the old dedicated floor layer.
|
|
|
|
|
|
pub(crate) floor: Floor,
|
|
|
|
|
|
/// Non-solid things placed off the main grid, drawn only where the grid cell is
|
|
|
|
|
|
/// empty (see [`Decoration`]). Normally empty; populated by save files.
|
|
|
|
|
|
pub(crate) decorations: Vec<Decoration>,
|
2026-06-25 19:16:46 -05:00
|
|
|
|
/// Current player position on this board. See [`PlayerPos`] for caveats
|
|
|
|
|
|
/// about its future. Game-global player *stats* live in [`crate::player::Player`].
|
2026-06-25 00:04:42 -05:00
|
|
|
|
pub player: PlayerPos,
|
2026-06-07 00:19:53 -05:00
|
|
|
|
/// 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-28 00:12:52 -05:00
|
|
|
|
/// Return a list of all `ObjectId`s currently on the board.
|
|
|
|
|
|
pub fn all_ids(&self) -> Vec<ObjectId> {
|
|
|
|
|
|
self.objects.keys().cloned().collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Returns a reference to the `(Glyph, Archetype)` cell at `(x, y)`.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Panics if `x` or `y` are out of bounds.
|
|
|
|
|
|
pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) {
|
|
|
|
|
|
&self.grid[y * self.width + x]
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Returns a mutable reference to the cell at `(x, y)`.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Panics if `x` or `y` are out of bounds.
|
|
|
|
|
|
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
let w = self.width;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
&mut self.grid[y * w + x]
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Replace the solid terrain (if any) at `(x, y)` with a transparent `Empty`
|
|
|
|
|
|
/// cell, revealing the floor beneath.
|
2026-06-21 22:04:10 -05:00
|
|
|
|
pub fn clear_solid(&mut self, x: usize, y: usize) {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
if self.in_bounds((x as i64, y as i64)) && self.get(x, y).1.behavior().solid {
|
|
|
|
|
|
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
|
|
|
|
|
}
|
2026-06-21 22:04:10 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Returns the glyph to display at `(x, y)`.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// With a single grid the draw order is a fixed precedence (no layer walk):
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// 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.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
///
|
2026-06-15 23:35:18 -05:00
|
|
|
|
/// Panics if out of bounds.
|
2026-06-07 00:19:53 -05:00
|
|
|
|
pub fn glyph_at(&self, x: usize, y: usize) -> Glyph {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// The player is rendered above everything (see the `Player` notes).
|
2026-06-28 00:12:52 -05:00
|
|
|
|
if self.player.x == x as i64 && self.player.y == y as i64 {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
return Glyph::player();
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Objects: a solid object always draws; otherwise the first non-transparent
|
|
|
|
|
|
// non-solid object (lets invisible trigger objects exist).
|
|
|
|
|
|
let mut nonsolid: Option<Glyph> = None;
|
|
|
|
|
|
for o in self.objects.values().filter(|o| o.x == x && o.y == y) {
|
|
|
|
|
|
if o.solid {
|
|
|
|
|
|
return o.glyph;
|
2026-06-15 23:35:18 -05:00
|
|
|
|
}
|
2026-07-10 23:16:28 -05:00
|
|
|
|
if nonsolid.is_none() && o.glyph.tile != 0 {
|
|
|
|
|
|
nonsolid = Some(o.glyph);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
}
|
2026-07-10 23:16:28 -05:00
|
|
|
|
}
|
|
|
|
|
|
if let Some(g) = nonsolid {
|
|
|
|
|
|
return g;
|
|
|
|
|
|
}
|
2026-06-07 00:19:53 -05:00
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// The grid cell: a solid always draws; a non-solid only if visible.
|
|
|
|
|
|
let (glyph, arch) = self.get(x, y);
|
|
|
|
|
|
if arch.behavior().solid || glyph.tile != 0 {
|
|
|
|
|
|
return *glyph;
|
|
|
|
|
|
}
|
2026-06-07 00:19:53 -05:00
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// A portal sits on its (transparent) grid cell.
|
|
|
|
|
|
if self.portals.iter().any(|p| p.x == x && p.y == y) {
|
|
|
|
|
|
return PortalDef::default_glyph();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The grid cell was empty: a decoration may show here.
|
|
|
|
|
|
if let Some(d) = self.decorations.iter().find(|d| d.x == x && d.y == y) {
|
|
|
|
|
|
return d.glyph;
|
2026-06-13 16:24:29 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Then the floor, else the canonical black empty cell.
|
|
|
|
|
|
self.floor
|
|
|
|
|
|
.glyph_at(x, y, self.width)
|
|
|
|
|
|
.unwrap_or_else(|| 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.
|
2026-06-28 00:12:52 -05:00
|
|
|
|
pub fn in_bounds(&self, pos: (i64, i64)) -> bool {
|
2026-06-07 00:19:53 -05:00
|
|
|
|
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.
|
2026-06-28 00:12:52 -05:00
|
|
|
|
if self.player.x == x as i64 && self.player.y == y as i64 {
|
2026-06-21 19:15:43 -05:00
|
|
|
|
return Some(Solid::player_at(x, y));
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
2026-06-21 19:15:43 -05:00
|
|
|
|
// A solid object shadows the cell it sits on; capture its behavior now.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
if let Some(id) = self.solid_object_id_at(x, y) {
|
2026-06-21 19:15:43 -05:00
|
|
|
|
let obj = &self.objects[&id];
|
|
|
|
|
|
let behavior = Behavior {
|
|
|
|
|
|
solid: obj.solid,
|
|
|
|
|
|
opaque: obj.opaque,
|
|
|
|
|
|
// ObjectDef stores pushability as a bool meaning "any direction".
|
|
|
|
|
|
pushable: if obj.pushable {
|
|
|
|
|
|
Pushable::Any
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Pushable::No
|
|
|
|
|
|
},
|
|
|
|
|
|
grab: obj.grab,
|
|
|
|
|
|
};
|
|
|
|
|
|
return Some(Solid::object_at(x, y, id, behavior));
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Otherwise the grid cell's terrain archetype may be solid (e.g. a wall).
|
|
|
|
|
|
let (glyph, arch) = *self.get(x, y);
|
|
|
|
|
|
if arch.behavior().solid {
|
|
|
|
|
|
return Some(Solid::terrain_at(x, y, glyph, arch));
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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 {
|
2026-06-21 19:15:43 -05:00
|
|
|
|
// The captured `pushable` is `Any` for the player and a pushable object,
|
|
|
|
|
|
// axis-constrained for directional crates, `No` otherwise.
|
|
|
|
|
|
self.solid_at(x, y)
|
|
|
|
|
|
.is_some_and(|s| s.pushable().allows(dir))
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 13:21:27 -05:00
|
|
|
|
/// The object that a move **into** `(x, y)` heading `dir` bumps, if any.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Walks the chain of pushable crates from the target cell in `dir` until it
|
|
|
|
|
|
/// reaches something that stops it, and reports the object it presses against:
|
|
|
|
|
|
/// - a solid object in the target cell (or at the end of a crate chain) is the
|
|
|
|
|
|
/// bumped object,
|
|
|
|
|
|
/// - open space or the player means nothing is bumped,
|
|
|
|
|
|
/// - a non-pushable terrain cell (a wall) blocks the chain with no object to bump.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This is what lets *any* solid — the player, another object, or a pushed
|
|
|
|
|
|
/// crate — trigger a `bump`: the bumper need not be an object, since we only
|
|
|
|
|
|
/// return the *bumped* object's id (the direction it came from is supplied by
|
|
|
|
|
|
/// the caller from its move direction).
|
|
|
|
|
|
pub fn bump_target(&self, x: usize, y: usize, dir: Direction) -> Option<ObjectId> {
|
|
|
|
|
|
let (dx, dy): (i64, i64) = dir.into();
|
|
|
|
|
|
let (mut cx, mut cy) = (x, y);
|
|
|
|
|
|
loop {
|
2026-07-09 00:18:40 -05:00
|
|
|
|
// Open space ends the walk with nothing bumped.
|
|
|
|
|
|
let solid = self.solid_at(cx, cy)?;
|
|
|
|
|
|
// A solid object — directly, or at the end of a crate chain — is the target.
|
|
|
|
|
|
if let Some(id) = solid.object_id() {
|
|
|
|
|
|
return Some(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
// The player is never itself "bumped"; a wall (non-pushable terrain) stops
|
|
|
|
|
|
// the chain with no object behind it. Only a pushable crate is walked
|
|
|
|
|
|
// through — reuse the already-fetched `solid` rather than re-scanning.
|
|
|
|
|
|
if solid.player() || !solid.pushable().allows(dir) {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let next = (cx as i64 + dx, cy as i64 + dy);
|
|
|
|
|
|
if !self.in_bounds(next) {
|
|
|
|
|
|
return None; // crate chain runs off the board
|
2026-07-08 13:21:27 -05:00
|
|
|
|
}
|
2026-07-09 00:18:40 -05:00
|
|
|
|
cx = next.0 as usize;
|
|
|
|
|
|
cy = next.1 as usize;
|
2026-07-08 13:21:27 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 00:19:53 -05:00
|
|
|
|
/// 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 {
|
2026-06-28 00:12:52 -05:00
|
|
|
|
let (dx, dy): (i64, i64) = dir.into();
|
2026-06-07 00:19:53 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-28 00:12:52 -05:00
|
|
|
|
let next = (cx as i64 + dx, cy as i64 + dy);
|
2026-06-07 00:19:53 -05:00
|
|
|
|
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
|
|
|
|
///
|
2026-06-21 19:15:43 -05:00
|
|
|
|
/// The **player** is always a blocker: a shift can't relocate the player, and
|
|
|
|
|
|
/// `apply_swap` refuses to overwrite it, so a cell holding the player is never
|
|
|
|
|
|
/// an acceptable shift destination.
|
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;
|
|
|
|
|
|
}
|
2026-06-28 00:12:52 -05:00
|
|
|
|
let (dx, dy): (i64, i64) = dir.into();
|
|
|
|
|
|
let next = (x as i64 + dx, y as i64 + dy);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
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 19:15:43 -05:00
|
|
|
|
// The player always blocks a shift: a shift can't relocate it and
|
|
|
|
|
|
// `apply_swap` refuses to overwrite it. (The player reads as pushable, so it
|
|
|
|
|
|
// must be excluded explicitly before the cell-ahead test below.)
|
|
|
|
|
|
if self.solid_at(nx, ny).is_some_and(|s| s.player()) {
|
|
|
|
|
|
return false;
|
2026-06-21 18:27:45 -05:00
|
|
|
|
}
|
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;
|
|
|
|
|
|
}
|
2026-06-28 00:12:52 -05:00
|
|
|
|
let (dx, dy): (i64, i64) = dir.into();
|
2026-06-07 00:19:53 -05:00
|
|
|
|
// 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));
|
2026-06-28 00:12:52 -05:00
|
|
|
|
cx = (cx as i64 + dx) as usize;
|
|
|
|
|
|
cy = (cy as i64 + dy) as usize;
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
|
|
|
|
|
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-07-10 23:16:28 -05:00
|
|
|
|
/// A solid object is relocated; otherwise the solid terrain archetype (a crate)
|
|
|
|
|
|
/// is moved, leaving a transparent cell behind so the floor shows through. The
|
|
|
|
|
|
/// caller guarantees the destination is already clear.
|
2026-06-28 00:12:52 -05:00
|
|
|
|
fn shift_solid(&mut self, x: usize, y: usize, dx: i64, dy: i64) {
|
|
|
|
|
|
let (tx, ty) = ((x as i64 + dx) as usize, (y as i64 + dy) as usize);
|
2026-06-21 19:15:43 -05:00
|
|
|
|
let Some(solid) = self.solid_at(x, y) else {
|
|
|
|
|
|
return; // nothing to shift
|
|
|
|
|
|
};
|
|
|
|
|
|
// A terrain cell leaves a transparent cell behind (revealing any floor); the
|
|
|
|
|
|
// player and objects carry no grid cell, so there is nothing to vacate. `place`
|
|
|
|
|
|
// captured the glyph/arch, so clearing the source first is safe.
|
2026-07-10 23:16:28 -05:00
|
|
|
|
if self.get(x, y).1.behavior().solid {
|
|
|
|
|
|
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
2026-06-07 00:19:53 -05:00
|
|
|
|
}
|
2026-06-21 19:15:43 -05:00
|
|
|
|
solid.place(self, tx, ty);
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// 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.
|
2026-06-20 17:53:47 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// A vacated grid cell becomes a transparent `Empty` so the floor shows through.
|
|
|
|
|
|
/// Panics if `(x, y)` is out of bounds.
|
2026-06-20 17:53:47 -05:00
|
|
|
|
pub fn place_archetype(&mut self, x: usize, y: usize, arch: Archetype, glyph: Glyph) {
|
|
|
|
|
|
if arch == Archetype::Empty {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Erase: drop every object in the cell and clear its grid cell (keep floor).
|
2026-06-20 17:53:47 -05:00
|
|
|
|
for id in self.object_ids_at(x, y) {
|
|
|
|
|
|
self.objects.remove(&id);
|
|
|
|
|
|
}
|
2026-07-10 23:16:28 -05:00
|
|
|
|
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
2026-06-20 17:53:47 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-10 23:16:28 -05:00
|
|
|
|
*self.get_mut(x, y) = (glyph, arch);
|
2026-06-20 17:53:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
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) {
|
2026-06-23 01:08:01 -05:00
|
|
|
|
use crate::builtin_scripts::builtin_tag;
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Collect first: the loop below mutates both the grid and the object map.
|
|
|
|
|
|
let mut found: Vec<(usize, usize, Glyph, Archetype)> = Vec::new();
|
|
|
|
|
|
for y in 0..self.height {
|
|
|
|
|
|
for x in 0..self.width {
|
|
|
|
|
|
let (glyph, arch) = *self.get(x, y);
|
|
|
|
|
|
if matches!(arch, Archetype::Builtin(_, _)) {
|
|
|
|
|
|
found.push((x, y, glyph, arch));
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-10 23:16:28 -05:00
|
|
|
|
for (x, y, glyph, arch) in found {
|
|
|
|
|
|
// Vacate the grid cell (revealing any floor beneath), then spawn the
|
2026-06-21 01:32:47 -05:00
|
|
|
|
// object — mirroring `resolve_entry`'s object template.
|
2026-07-10 23:16:28 -05:00
|
|
|
|
*self.get_mut(x, y) = (Glyph::transparent(), Archetype::Empty);
|
2026-06-23 01:08:01 -05:00
|
|
|
|
let Archetype::Builtin(b, _alias) = arch else { continue };
|
|
|
|
|
|
let beh = b.behavior();
|
2026-06-21 01:32:47 -05:00
|
|
|
|
let mut obj = ObjectDef::new(x, y);
|
|
|
|
|
|
obj.glyph = glyph;
|
2026-06-23 01:08:01 -05:00
|
|
|
|
obj.solid = beh.solid;
|
|
|
|
|
|
obj.opaque = beh.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).
|
2026-06-23 01:08:01 -05:00
|
|
|
|
obj.pushable = beh.pushable != crate::utils::Pushable::No;
|
|
|
|
|
|
obj.grab = beh.grab;
|
|
|
|
|
|
obj.builtin_script = Some(b.script());
|
|
|
|
|
|
// The compile-cache key and the BUILTIN_* tag are both derived from the
|
|
|
|
|
|
// alias (e.g. "BUILTIN_pusher_east"), so each alias gets its own cached
|
|
|
|
|
|
// AST — a tiny bit less sharing than before, but the source is identical.
|
|
|
|
|
|
let tag = builtin_tag(arch);
|
|
|
|
|
|
obj.script_name = Some(tag.clone());
|
|
|
|
|
|
obj.tags.insert(tag);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
self.add_object(obj);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-21 22:04:10 -05:00
|
|
|
|
|
|
|
|
|
|
/// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script
|
|
|
|
|
|
/// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log.
|
2026-06-28 00:12:52 -05:00
|
|
|
|
pub fn apply_shift(&mut self, cells: &[(i64, i64)]) -> Vec<LogLine> {
|
2026-06-21 22:04:10 -05:00
|
|
|
|
// Validate all the cells are in bounds, error if not:
|
|
|
|
|
|
if cells.iter().any(|&c| !self.in_bounds(c)) {
|
|
|
|
|
|
return vec![LogLine::error("Called shift() with a cell out of bounds")]
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:04:10 -05:00
|
|
|
|
// Get all the Solids at these cells:
|
|
|
|
|
|
let solids: Vec<_> = cells.iter().map(|&c| self.solid_at(c.0 as usize, c.1 as usize)).collect();
|
|
|
|
|
|
|
|
|
|
|
|
// Tell whether these are normally pushable in this direction. This doesn't count whether
|
|
|
|
|
|
// their target zone will be empty, just whether they would be willing to move that way at
|
|
|
|
|
|
// all. The direction comes in because of hcrates / vcrates: if their target cell is adjacent,
|
|
|
|
|
|
// then we'll check directions
|
|
|
|
|
|
//let mut pushable: Vec<bool> = Vec::with_capacity(solids.len());
|
|
|
|
|
|
|
|
|
|
|
|
let mut immobile = HashSet::new();
|
|
|
|
|
|
for (curr_idx, curr) in solids.iter().enumerate() {
|
|
|
|
|
|
let origin = cells[curr_idx];
|
|
|
|
|
|
let target = cells[(curr_idx + 1) % cells.len()];
|
|
|
|
|
|
|
|
|
|
|
|
// Whether these represent a single-cell h or v move.
|
|
|
|
|
|
let hmove = target.1 == origin.1 && (target.0 - origin.0).abs() == 1;
|
|
|
|
|
|
let vmove = target.0 == origin.0 && (target.1 - origin.1).abs() == 1;
|
|
|
|
|
|
|
|
|
|
|
|
// We are never allowed to push a thing that won't push
|
|
|
|
|
|
// We won't push a horizontal-only thing vertically
|
|
|
|
|
|
// We won't push a vertical-only thing horizontally
|
|
|
|
|
|
let pushable = curr.as_ref().map_or(Pushable::Any, |c| c.pushable());
|
|
|
|
|
|
if pushable == Pushable::No ||
|
|
|
|
|
|
pushable == Pushable::Vertical && hmove ||
|
|
|
|
|
|
pushable == Pushable::Horizontal && vmove {
|
|
|
|
|
|
immobile.insert(curr_idx);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:04:10 -05:00
|
|
|
|
// 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
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:04:10 -05:00
|
|
|
|
// Clear all the cells so everything can be placed:
|
|
|
|
|
|
for (curr_idx, curr) in cells.iter().enumerate() {
|
|
|
|
|
|
if !blocked.contains(&curr_idx) {
|
|
|
|
|
|
self.clear_solid(curr.0 as usize, curr.1 as usize);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:04:10 -05:00
|
|
|
|
// Now, move anything that we've decided is not blocked:
|
|
|
|
|
|
for (curr_idx, curr) in solids.iter().enumerate() {
|
|
|
|
|
|
if let Some(solid) = curr && !blocked.contains(&curr_idx) {
|
|
|
|
|
|
let target = cells[(curr_idx + 1) % cells.len()];
|
|
|
|
|
|
solid.place(self, target.0 as usize, target.1 as usize);
|
2026-06-21 18:27:45 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:04:10 -05:00
|
|
|
|
vec![]
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 00:41:38 -05:00
|
|
|
|
/// 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 obj in self.objects.values_mut() {
|
|
|
|
|
|
obj.queue.clear()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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-23 01:08:01 -05:00
|
|
|
|
use crate::archetype::{Archetype, Builtin};
|
2026-07-10 23:16:28 -05:00
|
|
|
|
use crate::floor::Floor;
|
2026-06-07 00:33:16 -05:00
|
|
|
|
use crate::glyph::Glyph;
|
|
|
|
|
|
use crate::object_def::ObjectDef;
|
2026-06-08 22:15:44 -05:00
|
|
|
|
use crate::utils::Direction;
|
2026-06-25 00:04:42 -05:00
|
|
|
|
use crate::utils::{ObjectId, PlayerPos};
|
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-07-10 23:16:28 -05:00
|
|
|
|
/// Builds an all-empty `w×h` board with the given player position and objects.
|
|
|
|
|
|
/// Assigns sequential ids (1..=n) to objects.
|
2026-06-15 23:35:18 -05:00
|
|
|
|
///
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// 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.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
pub(crate) fn open_board(
|
|
|
|
|
|
w: usize,
|
|
|
|
|
|
h: usize,
|
2026-06-28 00:12:52 -05:00
|
|
|
|
player: (i64, i64),
|
2026-06-07 00:33:16 -05:00
|
|
|
|
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-07-10 23:16:28 -05:00
|
|
|
|
grid: vec![(Glyph::transparent(), Archetype::Empty); w * h],
|
|
|
|
|
|
floor: Floor::Blank,
|
|
|
|
|
|
decorations: Vec::new(),
|
2026-06-25 00:04:42 -05:00
|
|
|
|
player: PlayerPos {
|
2026-06-15 23:35:18 -05:00
|
|
|
|
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-07-10 23:16:28 -05:00
|
|
|
|
/// Gives the board a uniform fixed floor glyph (the single-grid replacement for
|
|
|
|
|
|
/// the old separate floor layer).
|
2026-06-15 23:35:18 -05:00
|
|
|
|
pub(crate) fn add_floor(board: &mut Board, glyph: Glyph) {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
board.floor = Floor::Fixed(glyph);
|
2026-06-15 23:35:18 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Stamps a crate cell onto the grid.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
pub(crate) fn crate_at(board: &mut Board, x: usize, y: usize) {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
*board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Stamps a wall cell onto the grid.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
pub(crate) fn wall_at(board: &mut Board, x: usize, y: usize) {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
*board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
/// Stamps an arbitrary archetype cell onto the grid.
|
2026-06-07 00:33:16 -05:00
|
|
|
|
pub(crate) fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
*board.get_mut(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));
|
|
|
|
|
|
|
2026-06-21 19:15:43 -05:00
|
|
|
|
let wall = board.solid_at(1, 0).expect("a wall is solid");
|
|
|
|
|
|
assert_eq!(wall.archetype(), Some(Archetype::Wall));
|
2026-06-07 00:33:16 -05:00
|
|
|
|
assert!(!board.is_passable(1, 0));
|
|
|
|
|
|
|
2026-06-21 19:15:43 -05:00
|
|
|
|
let obj = board.solid_at(2, 0).expect("an object is solid");
|
|
|
|
|
|
let id = obj.object_id().expect("expected a solid object");
|
|
|
|
|
|
assert_eq!((board.objects[&id].x, board.objects[&id].y), (2, 0));
|
2026-06-07 00:33:16 -05:00
|
|
|
|
assert!(!board.is_passable(2, 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 18:27:45 -05:00
|
|
|
|
#[test]
|
2026-06-21 19:15:43 -05:00
|
|
|
|
fn grab_object_at_detects_a_gem() {
|
2026-06-21 18:27:45 -05:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-21 19:15:43 -05:00
|
|
|
|
assert!(
|
|
|
|
|
|
board.solid_at(1, 0).is_some_and(|s| s.player()),
|
|
|
|
|
|
"expected the player at its own cell"
|
|
|
|
|
|
);
|
2026-06-07 00:33:16 -05:00
|
|
|
|
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-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation
|
|
|
|
|
|
assert_eq!(board.get(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));
|
2026-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(0, 0).1, Archetype::Crate); // read-only
|
2026-06-21 01:32:47 -05:00
|
|
|
|
|
|
|
|
|
|
// 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]
|
2026-06-21 19:15:43 -05:00
|
|
|
|
fn can_shift_treats_the_player_as_a_blocker() {
|
|
|
|
|
|
// The player is always a blocker for a shift — even a grab gem may not shift
|
|
|
|
|
|
// onto it (grab now fires only on player movement, not on being shifted in).
|
2026-06-21 18:27:45 -05:00
|
|
|
|
let mut gem = ObjectDef::new(0, 0);
|
|
|
|
|
|
gem.grab = true;
|
|
|
|
|
|
gem.pushable = true;
|
|
|
|
|
|
let board = open_board(2, 1, (1, 0), vec![gem]);
|
2026-06-21 19:15:43 -05:00
|
|
|
|
assert!(!board.can_shift(0, 0, Direction::East));
|
2026-06-21 18:27:45 -05:00
|
|
|
|
|
2026-06-21 19:15:43 -05:00
|
|
|
|
// A plain crate likewise may not shift onto the player.
|
2026-06-21 18:27:45 -05:00
|
|
|
|
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-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(1, 0).1, Archetype::Empty);
|
|
|
|
|
|
assert_eq!(board.get(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-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(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-07-10 23:16:28 -05:00
|
|
|
|
// A fixed floor attribute, a wall on the grid at (0,0).
|
2026-06-15 23:35:18 -05:00
|
|
|
|
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-20 17:53:47 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn place_wall_keeps_floor_and_removes_solid_object() {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// A fixed floor attribute; a solid object sits on the grid at (1,0).
|
2026-06-20 17:53:47 -05:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// The wall landed on the grid; the floor attribute is untouched.
|
|
|
|
|
|
assert_eq!(board.get(1, 0), &(wall, Archetype::Wall));
|
2026-06-20 17:53:47 -05:00
|
|
|
|
// 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() {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// A crate already occupies the grid at (1,0).
|
2026-06-20 17:53:47 -05:00
|
|
|
|
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);
|
2026-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(1, 0), &(wall, Archetype::Wall));
|
2026-06-20 17:53:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn erase_removes_terrain_and_objects_but_keeps_floor() {
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// A fixed floor attribute, a wall on the grid, and a (non-solid) object at (1,0).
|
2026-06-20 17:53:47 -05:00
|
|
|
|
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());
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
// Grid cell cleared to transparent Empty; object removed; floor still there.
|
2026-06-20 17:53:47 -05:00
|
|
|
|
assert_eq!(
|
2026-07-10 23:16:28 -05:00
|
|
|
|
board.get(1, 0),
|
2026-06-20 17:53:47 -05:00
|
|
|
|
&(Glyph::transparent(), Archetype::Empty)
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(board.object_ids_at(1, 0).is_empty());
|
|
|
|
|
|
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![]);
|
2026-06-23 01:08:01 -05:00
|
|
|
|
stamp(&mut board, 0, 0, Archetype::Builtin(Builtin::Spinner, "spinner_cw"));
|
2026-06-21 01:32:47 -05:00
|
|
|
|
board.expand_builtin_archetypes();
|
|
|
|
|
|
|
|
|
|
|
|
// The terrain cell is vacated and a scripted object takes its place.
|
2026-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(0, 0).1, Archetype::Empty);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
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]
|
2026-06-21 22:35:35 -05:00
|
|
|
|
fn apply_shift_out_of_bounds_rejects_immediately() {
|
|
|
|
|
|
// apply_shift validates all cells upfront; any out-of-bounds cell causes immediate failure.
|
2026-06-21 01:32:47 -05:00
|
|
|
|
let mut board = open_board(3, 1, (2, 0), vec![]);
|
|
|
|
|
|
crate_at(&mut board, 0, 0);
|
2026-06-21 22:35:35 -05:00
|
|
|
|
let errs = board.apply_shift(&[(0, 0), (9, 0)]);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
assert_eq!(errs.len(), 1);
|
2026-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(0, 0).1, Archetype::Crate); // unchanged
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-21 22:35:35 -05:00
|
|
|
|
fn apply_shift_wall_stops_cascade_but_empty_limits_it() {
|
|
|
|
|
|
// A non-pushable Wall is immobile. 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), vec![]);
|
2026-06-21 01:32:47 -05:00
|
|
|
|
crate_at(&mut board, 0, 0);
|
2026-06-21 22:35:35 -05:00
|
|
|
|
// (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} (Wall, Pushable::No)
|
|
|
|
|
|
// 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)]);
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(0, 0).1, Archetype::Crate); // wrapped from (4,0)
|
|
|
|
|
|
assert_eq!(board.get(1, 0).1, Archetype::Crate); // moved from (0,0)
|
|
|
|
|
|
assert_eq!(board.get(2, 0).1, Archetype::Wall); // blocked, immobile
|
|
|
|
|
|
assert_eq!(board.get(3, 0).1, Archetype::Empty); // cleared, crate moved
|
|
|
|
|
|
assert_eq!(board.get(4, 0).1, Archetype::Crate); // moved from (3,0)
|
2026-06-21 01:32:47 -05:00
|
|
|
|
}
|
2026-06-21 18:27:45 -05:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-21 22:35:35 -05:00
|
|
|
|
fn apply_shift_hcrate_moves_horizontally_vcrate_stays() {
|
|
|
|
|
|
// In a horizontal (same-row) adjacent cycle:
|
|
|
|
|
|
// - HCrate (Pushable::Horizontal) moves freely (hmove, not blocked).
|
|
|
|
|
|
// - VCrate (Pushable::Vertical) is immobile (Vertical && hmove).
|
|
|
|
|
|
// Both can share the same pattern without blocking each other's neighbors.
|
|
|
|
|
|
|
|
|
|
|
|
// Subcase A: HCrate moves
|
|
|
|
|
|
{
|
|
|
|
|
|
let mut board = open_board(4, 1, (3, 0), vec![]);
|
|
|
|
|
|
stamp(&mut board, 0, 0, Archetype::HCrate);
|
|
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
// (2,0) empty
|
|
|
|
|
|
|
|
|
|
|
|
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(0, 0).1, Archetype::Empty); // HCrate moved out
|
|
|
|
|
|
assert_eq!(board.get(1, 0).1, Archetype::HCrate); // moved from (0,0)
|
|
|
|
|
|
assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0)
|
2026-06-21 22:35:35 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Subcase B: VCrate stays; Crate behind it still moves
|
|
|
|
|
|
{
|
|
|
|
|
|
let mut board = open_board(4, 1, (3, 0), vec![]);
|
|
|
|
|
|
stamp(&mut board, 0, 0, Archetype::VCrate);
|
|
|
|
|
|
crate_at(&mut board, 1, 0);
|
|
|
|
|
|
// (2,0) empty
|
|
|
|
|
|
|
|
|
|
|
|
// VCrate (idx 0): target (1,0), origin (0,0), hmove=true.
|
|
|
|
|
|
// Pushable::Vertical && hmove=true → immobile
|
|
|
|
|
|
// Backward: prev=idx 2, solids[2]=None → stop. blocked={0}.
|
|
|
|
|
|
// Crate at (1,0) is NOT in blocked, so it moves.
|
|
|
|
|
|
let _errs = board.apply_shift(&[(0, 0), (1, 0), (2, 0)]);
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(0, 0).1, Archetype::VCrate); // immobile
|
|
|
|
|
|
assert_eq!(board.get(1, 0).1, Archetype::Empty); // crate moved out
|
|
|
|
|
|
assert_eq!(board.get(2, 0).1, Archetype::Crate); // moved from (1,0)
|
2026-06-21 22:35:35 -05:00
|
|
|
|
}
|
2026-06-21 18:27:45 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-21 22:35:35 -05:00
|
|
|
|
fn apply_shift_vcrate_moves_vertically_hcrate_stays() {
|
|
|
|
|
|
// In a vertical (same-column) adjacent cycle:
|
|
|
|
|
|
// - VCrate (Pushable::Vertical) moves freely (vmove, not blocked).
|
|
|
|
|
|
// - HCrate (Pushable::Horizontal) is immobile (Horizontal && vmove).
|
|
|
|
|
|
|
|
|
|
|
|
// Subcase A: VCrate moves
|
|
|
|
|
|
{
|
|
|
|
|
|
let mut board = open_board(1, 4, (0, 3), vec![]);
|
|
|
|
|
|
stamp(&mut board, 0, 0, Archetype::VCrate);
|
|
|
|
|
|
stamp(&mut board, 0, 1, Archetype::Crate);
|
|
|
|
|
|
// (0,2) empty
|
|
|
|
|
|
|
|
|
|
|
|
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(0, 0).1, Archetype::Empty); // VCrate moved out
|
|
|
|
|
|
assert_eq!(board.get(0, 1).1, Archetype::VCrate); // moved from (0,0)
|
|
|
|
|
|
assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1)
|
2026-06-21 22:35:35 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Subcase B: HCrate stays; Crate behind it still moves
|
|
|
|
|
|
{
|
|
|
|
|
|
let mut board = open_board(1, 4, (0, 3), vec![]);
|
|
|
|
|
|
stamp(&mut board, 0, 0, Archetype::HCrate);
|
|
|
|
|
|
stamp(&mut board, 0, 1, Archetype::Crate);
|
|
|
|
|
|
// (0,2) empty
|
|
|
|
|
|
|
|
|
|
|
|
// HCrate (idx 0): target (0,1), origin (0,0), vmove=true.
|
|
|
|
|
|
// Pushable::Horizontal && vmove=true → immobile
|
|
|
|
|
|
// Backward: prev=idx 2, solids[2]=None → stop. blocked={0}.
|
|
|
|
|
|
// Crate at (0,1) is NOT in blocked, so it moves.
|
|
|
|
|
|
let _errs = board.apply_shift(&[(0, 0), (0, 1), (0, 2)]);
|
|
|
|
|
|
|
2026-07-10 23:16:28 -05:00
|
|
|
|
assert_eq!(board.get(0, 0).1, Archetype::HCrate); // immobile
|
|
|
|
|
|
assert_eq!(board.get(0, 1).1, Archetype::Empty); // crate moved out
|
|
|
|
|
|
assert_eq!(board.get(0, 2).1, Archetype::Crate); // moved from (0,1)
|
2026-06-21 22:35:35 -05:00
|
|
|
|
}
|
2026-06-21 18:27:45 -05:00
|
|
|
|
}
|
2026-06-21 22:35:35 -05:00
|
|
|
|
|
2026-06-15 23:35:18 -05:00
|
|
|
|
}
|