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

1686 lines
65 KiB
Rust
Raw Normal View History

2026-06-03 22:46:54 -05:00
use crate::log::LogLine;
2026-06-06 17:36:00 -05:00
use crate::script::{Action, Direction, ScriptHost};
2026-05-30 20:20:09 -05:00
use color::Rgba8;
2026-05-30 18:48:41 -05:00
use serde::{Deserialize, Serialize};
2026-06-04 23:52:33 -05:00
use std::cell::{Ref, RefCell, RefMut};
2026-05-30 18:48:41 -05:00
use std::collections::HashMap;
2026-05-30 20:20:09 -05:00
use std::hash::{Hash, Hasher};
2026-06-04 23:52:33 -05:00
use std::rc::Rc;
2026-06-04 20:11:55 -05:00
use std::time::Duration;
/// The visual representation of a single board cell.
///
2026-05-19 00:07:04 -05:00
/// `Glyph` holds everything needed to draw one cell on screen: which tile
/// index to display and what colors to use. It is stored per-cell (not per
/// archetype), so individual cells can vary their appearance independently.
///
/// `tile` is a left-to-right, top-to-bottom index into the board's bitmap
/// font. For the default CP437 font this matches the ASCII/CP437 code point.
///
/// `Glyph` values come from the map file palette and are set at load time.
/// The player is the only entity whose glyph is hardcoded at runtime
/// (see [`Glyph::player`]).
2026-06-06 18:49:45 -05:00
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Glyph {
2026-05-19 00:07:04 -05:00
/// Tile index into the board's bitmap font (left-to-right, top-to-bottom).
pub tile: u32,
/// Foreground color, applied to non-background pixels of the tile.
2026-05-30 20:20:09 -05:00
pub fg: Rgba8,
2026-05-19 00:07:04 -05:00
/// Background color, drawn as a filled rectangle behind the tile.
2026-05-30 20:20:09 -05:00
pub bg: Rgba8,
}
impl Hash for Glyph {
/// Hash via packed u32 representations so the impl stays in sync with Eq.
fn hash<H: Hasher>(&self, state: &mut H) {
self.tile.hash(state);
self.fg.to_u32().hash(state);
self.bg.to_u32().hash(state);
}
}
impl Glyph {
2026-06-03 22:46:54 -05:00
/// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue.
///
/// This is the only hardcoded glyph; all other glyphs come from the map
/// file palette. It will be removed once the player becomes a scripted
/// object with its own palette entry.
2026-06-04 23:52:33 -05:00
#[rustfmt::skip]
2026-05-19 00:07:04 -05:00
pub const fn player() -> Self {
Self {
tile: 64,
2026-06-03 22:46:54 -05:00
fg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, // white
bg: Rgba8 { r: 0, g: 0, b: 200, a: 255 }, // dark blue
2026-05-19 00:07:04 -05:00
}
}
}
2026-05-19 00:07:04 -05:00
/// Specifies a bitmap font for a board.
///
/// Each board can optionally specify a font image and tile dimensions.
/// When absent, the app's default embedded CP437 font is used.
#[derive(Clone, PartialEq, Eq)]
pub struct FontSpec {
/// Path to the PNG font image, relative to the working directory.
pub path: String,
/// Width of each tile in the font image, in pixels.
pub tile_w: u32,
/// Height of each tile in the font image, in pixels.
pub tile_h: u32,
}
2026-06-06 14:11:20 -05:00
/// Which directions a solid may be pushed in.
///
/// Only meaningful for `solid` cells (a non-solid cell is passable, so nothing is
/// ever pushed into it). `Crate` is [`Pushable::Any`]; the directional crates
/// constrain pushes to one axis.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Pushable {
/// Cannot be pushed.
No,
/// Pushable in any direction.
Any,
/// Pushable east/west only.
Horizontal,
/// Pushable north/south only.
Vertical,
}
impl Pushable {
/// Whether a push in `dir` is allowed.
pub fn allows(self, dir: Direction) -> bool {
match self {
Pushable::No => false,
Pushable::Any => true,
Pushable::Horizontal => matches!(dir, Direction::East | Direction::West),
Pushable::Vertical => matches!(dir, Direction::North | Direction::South),
}
}
}
/// The behavioral properties of a board cell at runtime.
///
/// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It
/// contains the properties the engine needs to simulate a cell — currently
2026-06-06 01:37:19 -05:00
/// solidity, opacity, and pushability. Future properties (shootable, etc.) can
/// be added here without changing call sites.
///
2026-06-06 01:37:19 -05:00
/// For scripted objects, solidity and opacity are stored directly on
/// [`ObjectDef`] and will eventually be overridable by Rhai scripts at runtime.
#[derive(Copy, Clone, Debug)]
pub struct Behavior {
2026-06-06 01:37:19 -05:00
/// Whether this cell blocks / participates in movement. A solid cell stops a
/// mover (and is the only kind of cell that can later be pushed or receive a
/// collision event). This is the inverse of the old `passable` flag.
pub solid: bool,
/// Whether this cell blocks line of sight (reserved for future rendering).
pub opaque: bool,
2026-06-06 14:11:20 -05:00
/// Which directions a mover can shove this cell in (only meaningful when `solid`).
pub pushable: Pushable,
}
/// A class of board cell, encoding its default behavior and appearance.
///
/// `Archetype` is an enum of the element types the engine knows about. Each
/// variant provides a default [`Behavior`] (via [`Archetype::behavior`]) and a
/// default [`Glyph`] (via [`Archetype::default_glyph`]) used when the editor
/// stamps a cell.
///
/// Map files reference archetypes by [`name`](Archetype::name) (e.g. `"wall"`),
/// so the list of variants can be reordered without breaking saved games.
///
/// ## Object special case
///
/// `Archetype::Object` currently returns a static default `Behavior`.
/// TODO: In the future, Object passability and opacity will come from the cell's Rhai script
/// rather than from this enum. [`Board::is_passable`] will need a special branch
/// at that point. `ErrorBlock` is used as a sentinel for unrecognized archetype
/// names in map files — it should never appear in a valid board.
2026-05-30 18:48:41 -05:00
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum Archetype {
/// An open cell; the player and other entities can pass through it.
Empty,
/// A solid wall; impassable and opaque.
Wall,
2026-06-06 14:11:20 -05:00
/// A solid, pushable box. Walking into it shoves it one cell in the same
/// direction (cascading through a chain of crates) if there is open space.
Crate,
/// A crate that can only be pushed east/west (horizontal axis). Glyph ↔.
HCrate,
/// A crate that can only be pushed north/south (vertical axis). Glyph ↕.
VCrate,
/// Sentinel for map files that reference an unknown archetype name.
/// Renders as a yellow `?` on red to make the error visible in-game.
ErrorBlock,
}
impl Archetype {
/// Returns the default [`Behavior`] for this archetype.
///
/// For `Object`, this is a placeholder until Rhai scripts drive the behavior.
pub fn behavior(&self) -> Behavior {
match self {
2026-05-19 00:07:04 -05:00
Archetype::Empty => Behavior {
2026-06-06 01:37:19 -05:00
solid: false,
2026-05-19 00:07:04 -05:00
opaque: false,
2026-06-06 14:11:20 -05:00
pushable: Pushable::No,
2026-05-19 00:07:04 -05:00
},
Archetype::Wall => Behavior {
2026-06-06 01:37:19 -05:00
solid: true,
2026-05-19 00:07:04 -05:00
opaque: true,
2026-06-06 14:11:20 -05:00
pushable: Pushable::No,
},
Archetype::Crate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Any,
},
Archetype::HCrate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Horizontal,
},
Archetype::VCrate => Behavior {
solid: true,
opaque: true,
pushable: Pushable::Vertical,
2026-05-19 00:07:04 -05:00
},
Archetype::ErrorBlock => Behavior {
2026-06-06 01:37:19 -05:00
solid: true,
2026-05-19 00:07:04 -05:00
opaque: true,
2026-06-06 14:11:20 -05:00
pushable: Pushable::No,
2026-05-19 00:07:04 -05:00
},
}
}
/// Returns the canonical name used to reference this archetype in map files.
pub fn name(&self) -> &'static str {
match self {
2026-05-19 00:07:04 -05:00
Archetype::Empty => "empty",
Archetype::Wall => "wall",
2026-06-06 14:11:20 -05:00
Archetype::Crate => "crate",
Archetype::HCrate => "hcrate",
Archetype::VCrate => "vcrate",
Archetype::ErrorBlock => "error_block",
}
}
/// Returns the default glyph painted when the editor stamps this archetype.
///
/// This glyph is used only for new cells created in the editor; existing
/// cells retain their own per-cell glyph.
2026-06-04 23:52:33 -05:00
#[rustfmt::skip]
pub fn default_glyph(&self) -> Glyph {
match self {
2026-05-19 00:07:04 -05:00
Archetype::Empty => Glyph {
tile: 32,
2026-05-30 20:20:09 -05:00
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
2026-05-19 00:07:04 -05:00
},
Archetype::Wall => Glyph {
tile: 35,
2026-05-30 20:20:09 -05:00
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 },
2026-05-19 00:07:04 -05:00
},
2026-06-06 14:11:20 -05:00
Archetype::Crate => Glyph {
tile: 254, // CP437 ■ (small filled square)
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // light gray on black
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::HCrate => Glyph {
tile: 29, // CP437 ↔ (left-right arrow) — pushable east/west
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
Archetype::VCrate => Glyph {
tile: 18, // CP437 ↕ (up-down arrow) — pushable north/south
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
},
// Visually distinct so malformed map files are immediately obvious.
2026-05-19 00:07:04 -05:00
Archetype::ErrorBlock => Glyph {
tile: 63,
2026-05-30 20:20:09 -05:00
fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow on red
bg: Rgba8 { r: 255, g: 0, b: 0, a: 255 },
2026-05-19 00:07:04 -05:00
},
}
}
}
impl TryFrom<&str> for Archetype {
type Error = String;
/// Parses an archetype by its map-file name.
///
/// Returns an error for unrecognized names; the caller should substitute
/// [`Archetype::ErrorBlock`] and log the error so the problem is visible.
fn try_from(name: &str) -> Result<Self, Self::Error> {
match name {
2026-05-19 00:07:04 -05:00
"empty" => Ok(Archetype::Empty),
"wall" => Ok(Archetype::Wall),
2026-06-06 14:11:20 -05:00
"crate" => Ok(Archetype::Crate),
"hcrate" => Ok(Archetype::HCrate),
"vcrate" => Ok(Archetype::VCrate),
2026-05-30 19:25:10 -05:00
// "object" is intentionally absent: objects are not valid palette
// entries in map files. They live in [[objects]] with their own glyph.
2026-05-19 00:07:04 -05:00
_ => Err(format!("unknown archetype: {name}")),
}
}
}
/// The archetypes available for placement in the editor, in display order.
///
/// `ErrorBlock` is excluded — it is a sentinel for load errors, not a valid
/// editing choice.
2026-06-06 14:11:20 -05:00
pub const ALL_ARCHETYPES: &[Archetype] = &[
Archetype::Empty,
Archetype::Wall,
Archetype::Crate,
Archetype::HCrate,
Archetype::VCrate,
];
/// A scripted object placed on the board, loaded from a map file.
///
/// `ObjectDef` represents a tile that has Rhai script attached to it.
/// Scripts can respond to events like the player touching or shooting the
/// tile. Objects are parsed from `[[objects]]` entries in `.toml` map files
/// and stored on [`Board`].
///
2026-05-30 19:25:10 -05:00
/// Objects are rendered as an overlay on top of the board grid — the grid
/// cell at `(x, y)` holds the background (floor) that is revealed if the
/// object moves away. `glyph` is owned by the object itself and may be
/// mutated by its Rhai script at runtime.
///
2026-05-30 18:48:41 -05:00
/// Script text lives in [`Board::scripts`]; this struct holds only the name
/// used to look it up. Two `ObjectDef`s with the same `script_name` share
2026-06-04 20:11:55 -05:00
/// source text but run with independent Rhai scopes (see [`crate::script`]).
2026-05-30 18:48:41 -05:00
///
2026-06-04 20:11:55 -05:00
/// Scripts are executed by [`crate::script::ScriptHost`]: an object's optional
/// `init()` and `tick(dt)` functions are called via [`GameState::run_init`] and
/// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work.
pub struct ObjectDef {
/// Column of this object on the board (0-indexed).
pub x: usize,
/// Row of this object on the board (0-indexed).
pub y: usize,
2026-05-30 19:25:10 -05:00
/// Visual representation of this object. Owned by the object (not derived
/// from the grid cell), so scripts can change tile, fg, and bg at runtime.
pub glyph: Glyph,
2026-06-06 01:37:19 -05:00
/// Whether the object blocks / participates in movement. A solid object stops
/// a mover walking into it; at most one solid (object or grid archetype) may
/// occupy a cell. Inverse of the old `passable` flag.
pub solid: bool,
2026-05-30 21:22:36 -05:00
/// Whether the object blocks line of sight / FOV for the player
pub opaque: bool,
2026-06-06 01:37:19 -05:00
/// Whether the object can be shoved by a mover. Unused for now (no push
/// mechanic yet); defaults to `false`.
pub pushable: bool,
2026-05-30 18:48:41 -05:00
/// Name of the Rhai script in [`Board::scripts`] that drives this object.
/// `None` means this object has no script yet.
pub script_name: Option<String>,
}
2026-05-30 21:22:36 -05:00
impl ObjectDef {
/// Returns the default glyph for a newly placed object: tile 63 (`?`) in yellow on black.
2026-06-04 23:52:33 -05:00
#[rustfmt::skip]
2026-05-30 21:22:36 -05:00
pub fn default_glyph() -> Glyph {
Glyph {
tile: 63,
fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}
}
/// Creates a new object at `(x, y)` with default glyph and blocking behavior.
///
2026-06-06 01:37:19 -05:00
/// Defaults: `solid = true`, `opaque = true`, `pushable = false`, no script.
/// These match the serde defaults in the map file format so new objects
/// round-trip correctly.
pub fn new(x: usize, y: usize) -> Self {
Self {
x,
y,
glyph: Self::default_glyph(),
2026-06-06 01:37:19 -05:00
solid: true,
opaque: true,
2026-06-06 01:37:19 -05:00
pushable: false,
script_name: None,
}
}
2026-05-30 21:22:36 -05:00
}
2026-06-06 01:37:19 -05:00
/// The single solid occupant of a board cell, returned by [`Board::solid_at`].
///
2026-06-06 17:36:00 -05:00
/// At most one solid — the player, a grid [`Archetype`], *or* an [`ObjectDef`] — may
/// occupy a cell (the invariant enforced at load time), so this represents the one
/// thing a mover would collide with there.
2026-06-06 01:37:19 -05:00
pub enum Solid<'a> {
2026-06-06 17:36:00 -05:00
/// The player occupies the cell. The player is solid (it blocks movers) and
/// pushable in any direction (see [`Board::is_pushable`]).
Player,
2026-06-06 01:37:19 -05:00
/// The cell's grid archetype is itself solid (e.g. [`Archetype::Wall`]).
Cell(Archetype),
/// A solid [`ObjectDef`] occupies the cell.
Object(&'a ObjectDef),
}
/// A portal that teleports the player to a named entry point on another board.
///
/// Portals are loaded from `[[portals]]` entries in `.toml` map files and
/// stored on [`Board`]. When the player steps onto a portal's cell, they
/// should be moved to `target_entry` on the board named by `target_map`.
///
/// **Not yet runtime-wired.** Portal navigation (multi-board loading and
/// switching) is a future feature.
2026-05-30 18:48:41 -05:00
#[derive(Deserialize, Serialize)]
pub struct PortalDef {
/// Column of this portal on the board (0-indexed).
pub x: usize,
/// Row of this portal on the board (0-indexed).
pub y: usize,
/// File name (without extension) of the target board, e.g. `"cave"`.
pub target_map: String,
/// Named entry point on the target board where the player arrives.
pub target_entry: String,
}
/// The player's current position on the board.
///
/// The player is currently a special entity rendered on top of the board
/// rather than being stored as a board cell. This is expected to change:
/// the player will eventually become a scripted object that responds to
/// input events, at which point this struct may be removed or made optional.
2026-06-06 14:11:20 -05:00
/// See the "player may become an object" notes in `CLAUDE.md` for the design
/// tensions this implies.
#[derive(Copy, Clone)]
pub struct Player {
/// Column position (0-indexed, increasing rightward).
pub x: i32,
/// Row position (0-indexed, increasing downward).
pub y: i32,
}
/// 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.
pub struct Board {
2026-05-30 18:48:41 -05:00
/// 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,
/// Row-major grid of `(Glyph, Archetype)` pairs. Use [`Board::get`] to
/// access by `(x, y)` coordinates.
pub(crate) cells: Vec<(Glyph, Archetype)>,
2026-06-06 18:49:45 -05:00
/// Row-major cache of the visual floor layer: the glyph drawn for a cell when
/// it would otherwise render as [`Archetype::Empty`]. Computed once at load
/// from [`floor_spec`](Board::floor_spec) (see [`crate::floor::build_floor`]).
/// When a map declares no `[floor]`, every entry is black-on-black space (the
/// historical look of an empty cell). Read it via [`Board::display_glyph`].
pub(crate) floor: Vec<Glyph>,
/// The raw `[floor]` declaration, kept so [`crate::map_file::save`] can
/// round-trip it. `None` when the map declared no floor. (Generators
/// re-randomize on reload; literal glyph grids are preserved exactly.)
pub(crate) floor_spec: Option<crate::floor::FloorSpec>,
/// Current player position. See [`Player`] for caveats about its future.
pub player: Player,
/// Scripted objects on this board. Parsed from the map file; not yet active.
pub objects: Vec<ObjectDef>,
/// Portals on this board. Parsed from the map file; not yet active.
pub portals: Vec<PortalDef>,
2026-05-19 00:07:04 -05:00
/// Optional font override for this board. When `None`, the app default is used.
pub font: Option<FontSpec>,
2026-05-31 23:51:51 -05:00
/// Integer scale factor applied to tiles when rendering this board. 1 = natural size.
pub zoom: u32,
2026-05-30 18:48:41 -05:00
/// Named Rhai scripts available on this board: script name → source text.
///
/// All script source lives here; [`ObjectDef`]s and [`Board::board_script_name`]
/// reference entries by name. This means multiple objects can share the same
/// source while each running instance gets its own Rhai scope at runtime.
pub scripts: HashMap<String, String>,
/// Name of the board-level script in [`Board::scripts`], if any.
///
/// A board script runs on the board as a whole (e.g. `on_enter`, `on_tick`)
/// rather than being tied to a specific object cell.
pub board_script_name: Option<String>,
2026-06-06 14:11:20 -05:00
/// 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>,
}
impl Board {
/// Returns a reference to the cell at `(x, y)`.
///
/// The cell is a `(Glyph, Archetype)` tuple. Panics if `x` or `y` are
/// out of bounds.
pub fn get(&self, x: usize, y: usize) -> &(Glyph, Archetype) {
&self.cells[y * self.width + x]
}
/// Returns a mutable reference to the cell at `(x, y)`.
///
/// Panics if `x` or `y` are out of bounds.
pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, Archetype) {
&mut self.cells[y * self.width + x]
}
2026-06-06 18:49:45 -05:00
/// Returns the glyph to draw for the **static layer** of cell `(x, y)` — the
/// grid archetype, or the floor underneath an [`Archetype::Empty`] cell.
///
/// This is the single source of truth front-ends use for the non-object,
/// non-player layer: a wall/crate draws its own per-cell glyph, while an empty
/// cell draws its [`floor`](Board::floor) glyph. An `Empty` cell's *own* glyph
/// is intentionally ignored — the floor layer supersedes it (so a cell vacated
/// by a pushed crate reveals the floor, not black). Panics if out of bounds.
pub fn display_glyph(&self, x: usize, y: usize) -> Glyph {
let (glyph, arch) = self.get(x, y);
if *arch == Archetype::Empty {
self.floor[y * self.width + x]
} else {
*glyph
}
}
2026-06-06 14:11:20 -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()
}
/// The nonfatal errors collected while loading this board, newest last.
pub fn load_errors(&self) -> &[LogLine] {
&self.load_errors
}
2026-05-30 20:20:09 -05:00
/// Returns a slice of all cells in row-major order.
///
/// Useful for iterating over the full board (e.g. to collect unique glyphs).
pub fn cells(&self) -> &[(Glyph, Archetype)] {
&self.cells
}
2026-06-06 01:37:19 -05:00
/// Returns the single solid entity occupying `(x, y)`, if any.
///
2026-06-06 01:37:19 -05:00
/// Checks objects first, 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.
2026-06-06 01:37:19 -05:00
pub fn solid_at(&self, x: usize, y: usize) -> Option<Solid<'_>> {
2026-06-06 17:36:00 -05:00
// 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-06 01:37:19 -05:00
// A solid object shadows the grid cell it sits on.
2026-06-04 23:52:33 -05:00
if let Some(obj) = self.object_at(x, y)
2026-06-06 01:37:19 -05:00
&& obj.solid
2026-06-04 23:52:33 -05:00
{
2026-06-06 01:37:19 -05:00
return Some(Solid::Object(obj));
}
// Otherwise the grid archetype itself may be solid (e.g. a wall).
let arch = self.get(x, y).1;
if arch.behavior().solid {
return Some(Solid::Cell(arch));
2026-05-30 19:25:10 -05:00
}
2026-06-06 01:37:19 -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()
2026-05-30 19:25:10 -05:00
}
2026-06-06 14:11:20 -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) {
2026-06-06 17:36:00 -05:00
Some(Solid::Player) => true, // the player is pushable in any direction
2026-06-06 14:11:20 -05:00
Some(Solid::Cell(a)) => a.behavior().pushable.allows(dir),
Some(Solid::Object(o)) => o.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;
}
}
/// 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)`.
///
/// A solid object is relocated; otherwise the grid archetype (a crate) is
/// moved, leaving `Empty` floor behind (the grid has no separate floor layer).
/// 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-06 17:36:00 -05:00
// The player owns its cell, so move it before considering objects/grid.
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;
} else if let Some(idx) = self.object_index_at(x, y)
2026-06-06 14:11:20 -05:00
&& self.objects[idx].solid
{
self.objects[idx].x = tx;
self.objects[idx].y = ty;
} else {
let moved = *self.get(x, y);
*self.get_mut(tx, ty) = moved;
*self.get_mut(x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty);
}
}
2026-05-30 19:25:10 -05:00
/// Returns the index into [`Board::objects`] of the object at `(x, y)`, if any.
2026-05-30 21:22:36 -05:00
pub fn object_index_at(&self, x: usize, y: usize) -> Option<usize> {
2026-05-30 19:25:10 -05:00
self.objects.iter().position(|o| o.x == x && o.y == y)
}
2026-05-30 21:22:36 -05:00
/// Returns a borrow of the actual object at `(x, y)` if any
pub fn object_at(&self, x: usize, y: usize) -> Option<&ObjectDef> {
self.object_index_at(x, y).map(|idx| &self.objects[idx])
}
}
2026-06-04 23:52:33 -05:00
/// Holds the active game world and provides game-logic operations.
///
/// `GameState` is the boundary between the engine (rendering, input) and the
2026-06-05 00:12:57 -05:00
/// game data. It owns the board (behind a shared `Rc<RefCell<Board>>`),
2026-06-04 23:52:33 -05:00
/// the message log, and the [`ScriptHost`] driving object scripts. Front-ends and
/// internal logic reach the board through [`board`](GameState::board) /
/// [`board_mut`](GameState::board_mut). Scripts read the board directly and
/// request mutations as commands, applied by [`apply_commands`](GameState::apply_commands)
/// after each script batch.
pub struct GameState {
2026-06-04 23:52:33 -05:00
/// The scriptable world, shared with the script host's read getters.
2026-06-05 00:12:57 -05:00
board: Rc<RefCell<Board>>,
2026-06-03 22:46:54 -05:00
/// The in-game message log, oldest first (newest pushed at the end).
pub log: Vec<LogLine>,
2026-06-04 20:11:55 -05:00
/// The Rhai scripting runtime driving this board's scripted objects.
scripts: ScriptHost,
}
impl GameState {
/// Creates a `GameState` from a pre-loaded [`Board`].
2026-06-04 20:11:55 -05:00
///
/// Compiles the board's object scripts but does **not** run any of them;
/// call [`GameState::run_init`] once the game is ready to start. Any script
/// compile errors are surfaced into the log here.
pub fn new(board: Board) -> Self {
2026-06-05 00:12:57 -05:00
let board = Rc::new(RefCell::new(board));
let scripts = ScriptHost::new(&board);
2026-06-04 20:11:55 -05:00
let mut state = Self {
2026-06-05 00:12:57 -05:00
board,
2026-06-03 22:46:54 -05:00
log: Vec::new(),
2026-06-04 20:11:55 -05:00
scripts,
};
// Surface any compile-time script errors collected during ScriptHost::new.
2026-06-06 17:36:00 -05:00
state.drain_errors();
2026-06-04 20:11:55 -05:00
state
2026-06-03 22:46:54 -05:00
}
2026-06-04 23:52:33 -05:00
/// Borrows the active board for reading (e.g. by a front-end renderer).
pub fn board(&self) -> Ref<'_, Board> {
2026-06-05 00:12:57 -05:00
self.board.borrow()
2026-06-04 23:52:33 -05:00
}
/// Borrows the active board for mutation.
pub fn board_mut(&self) -> RefMut<'_, Board> {
2026-06-05 00:12:57 -05:00
self.board.borrow_mut()
2026-06-04 23:52:33 -05:00
}
2026-06-03 22:46:54 -05:00
/// Appends a styled message to the log.
pub fn log(&mut self, line: LogLine) {
self.log.push(line);
}
2026-06-06 17:36:00 -05:00
/// Runs the `init()` hook of every scripted object, pumps their queues, and
/// resolves the resulting actions. Call once, after the whole map is loaded and
/// the game is about to start — never during map deserialization, since a script
/// may inspect the board.
2026-06-04 20:11:55 -05:00
pub fn run_init(&mut self) {
self.scripts.run_init();
2026-06-06 17:36:00 -05:00
self.resolve();
2026-06-04 20:11:55 -05:00
}
2026-06-06 17:36:00 -05:00
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
/// Called once per frame by the front-end's game loop. Counts down object
/// cooldowns, drives every scripted object's `tick(dt)` hook (pumping each queue),
/// then resolves the actions that were promoted onto the board queue.
2026-06-03 23:21:05 -05:00
pub fn tick(&mut self, dt: Duration) {
2026-06-06 17:36:00 -05:00
let secs = dt.as_secs_f64();
self.scripts.advance_timers(secs);
self.scripts.run_tick(secs);
self.resolve();
}
/// Drains errors collected by the script host into the game log.
fn drain_errors(&mut self) {
// TODO: errors are only logged for now. This is the place to halt execution /
// set an error state when a script faults.
let errors = self.scripts.take_errors();
self.log.extend(errors);
}
/// Resolves the board queue: applies each promoted action to the board, then fires
/// the `bump` hooks the moves triggered.
///
/// Done in two phases so no `board_mut` borrow is held while `bump` scripts run
/// (they read the board through its getters): phase A mutates the board and records
/// `(bumped_object, bumper)` pairs; phase B fires the bumps after the borrow drops.
fn resolve(&mut self) {
let actions = self.scripts.take_board_queue();
// Logs are collected here rather than pushed inline, since the board borrow
// below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new();
let mut bumps: Vec<(usize, i64)> = Vec::new();
{
let mut board = self.board_mut();
for ba in actions {
match ba.action {
Action::Move(dir) => {
if let Some(bumped) = step_object(&mut board, ba.source, dir) {
bumps.push((bumped, ba.source as i64));
}
}
Action::SetTile(tile) => {
if let Some(obj) = board.objects.get_mut(ba.source) {
obj.glyph.tile = tile;
}
2026-06-04 23:52:33 -05:00
}
2026-06-06 17:36:00 -05:00
Action::Log(line) => logs.push(line),
2026-06-04 23:52:33 -05:00
}
}
}
2026-06-06 17:36:00 -05:00
self.log.extend(logs);
for (bumped, bumper) in bumps {
self.scripts.run_bump(bumped, bumper);
2026-06-04 23:52:33 -05:00
}
2026-06-06 17:36:00 -05:00
self.drain_errors();
2026-06-03 23:21:05 -05:00
}
2026-06-06 14:11:20 -05:00
/// Attempts to move the player one cell in `dir`.
///
2026-06-06 14:11:20 -05:00
/// The move is ignored if the target cell is out of bounds, or it is neither
2026-06-06 17:36:00 -05:00
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the
/// caller does not need to check). If the target cell holds a solid object, that
/// object's `bump(-1)` hook fires (whether or not the player ends up moving).
2026-06-06 14:11:20 -05:00
pub fn try_move(&mut self, dir: Direction) {
2026-06-06 17:36:00 -05:00
let bumped;
{
let (dx, dy): (i32, i32) = dir.into();
let mut board = self.board_mut();
let target = (board.player.x + dx, board.player.y + dy);
if !board.in_bounds(target) {
return;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// A solid object in the way is bumped by the player (id -1).
bumped = match board.solid_at(nx, ny) {
Some(Solid::Object(_)) => board.object_index_at(nx, ny),
_ => None,
};
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
board.player.x = nx as i32;
board.player.y = ny as i32;
}
2026-06-06 14:11:20 -05:00
}
2026-06-06 17:36:00 -05:00
if let Some(idx) = bumped {
self.scripts.run_bump(idx, -1);
self.drain_errors();
}
}
2026-05-19 00:07:04 -05:00
}
2026-06-03 23:21:05 -05:00
2026-06-06 17:36:00 -05:00
/// Moves object `idx` one cell in `dir` on `board`, returning the index of a solid
/// object it bumped (its target cell was occupied by that object), if any.
///
/// The move itself proceeds only if the target is in bounds and either passable or a
/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The
/// bump is recorded whenever a solid object occupies the target — whether it gets
/// pushed aside or blocks the move — since something tried to move into it. Walls and
/// crates carry no script, so only solid objects yield a bump.
fn step_object(board: &mut Board, idx: usize, dir: Direction) -> Option<usize> {
let (dx, dy): (i32, i32) = dir.into();
let (ox, oy) = board.objects.get(idx).map(|o| (o.x, o.y))?;
let target = (ox as i32 + dx, oy as i32 + dy);
if !board.in_bounds(target) {
return None;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// Capture the bumped object before any push relocates it (its index is stable).
let bumped = match board.solid_at(nx, ny) {
Some(Solid::Object(_)) => board.object_index_at(nx, ny),
_ => None,
};
if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) {
board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise
let obj = &mut board.objects[idx];
obj.x = nx;
obj.y = ny;
}
bumped
}
2026-06-03 23:21:05 -05:00
#[cfg(test)]
mod tests {
use super::*;
2026-06-04 20:11:55 -05:00
/// Builds a 1×1 board with a single object that optionally references a
/// script, plus the given `(name, source)` script table entries.
fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> Board {
let mut object = ObjectDef::new(0, 0);
object.script_name = object_script.map(str::to_string);
2026-06-03 23:21:05 -05:00
Board {
name: "test".into(),
width: 1,
height: 1,
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)],
2026-06-06 18:49:45 -05:00
floor: vec![Archetype::Empty.default_glyph()],
floor_spec: None,
2026-06-03 23:21:05 -05:00
player: Player { x: 0, y: 0 },
2026-06-04 20:11:55 -05:00
objects: vec![object],
2026-06-03 23:21:05 -05:00
portals: Vec::new(),
font: None,
zoom: 1,
2026-06-04 20:11:55 -05:00
scripts: scripts
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
2026-06-03 23:21:05 -05:00
board_script_name: None,
2026-06-06 14:11:20 -05:00
load_errors: Vec::new(),
2026-06-03 23:21:05 -05:00
}
}
2026-06-04 23:52:33 -05:00
/// An `ObjectDef` at `(x, y)` bound to the named script.
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
let mut o = ObjectDef::new(x, y);
o.script_name = Some(script.to_string());
o
}
/// Builds an all-empty (passable) `w×h` board with the given player position,
/// objects, and `(name, source)` scripts — room for movement, unlike the 1×1
/// `board_with_object`.
fn open_board(
w: usize,
h: usize,
player: (i32, i32),
objects: Vec<ObjectDef>,
scripts: &[(&str, &str)],
) -> Board {
Board {
name: "test".into(),
width: w,
height: h,
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h],
2026-06-06 18:49:45 -05:00
floor: vec![Archetype::Empty.default_glyph(); w * h],
floor_spec: None,
2026-06-04 23:52:33 -05:00
player: Player {
x: player.0,
y: player.1,
},
objects,
portals: Vec::new(),
font: None,
zoom: 1,
scripts: scripts
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
board_script_name: None,
2026-06-06 14:11:20 -05:00
load_errors: Vec::new(),
2026-06-04 23:52:33 -05:00
}
}
2026-06-04 20:11:55 -05:00
/// Flattens each log line into a single string for easy assertions.
fn log_texts(game: &GameState) -> Vec<String> {
game.log
.iter()
.map(|line| line.spans.iter().map(|s| s.text.as_str()).collect())
.collect()
}
2026-06-06 01:37:19 -05:00
#[test]
fn solid_at_reports_wall_object_and_empty() {
2026-06-06 17:36:00 -05:00
// A 4×1 board: empty floor, a wall, an object, and the player parked at (3,0)
// (kept off the asserted cells, since the player is itself solid).
let mut board = open_board(4, 1, (3, 0), vec![], &[]);
2026-06-06 01:37:19 -05:00
board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall);
// A solid object on the otherwise-empty cell (2, 0).
board.objects.push(ObjectDef::new(2, 0)); // solid by default
// Empty floor: nothing solid.
assert!(board.solid_at(0, 0).is_none());
assert!(board.is_passable(0, 0));
// Wall cell: the grid archetype is the solid.
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));
// Object cell: the solid object shadows the empty floor under it.
match board.solid_at(2, 0) {
Some(Solid::Object(o)) => assert_eq!((o.x, o.y), (2, 0)),
_ => panic!("expected Solid::Object"),
}
assert!(!board.is_passable(2, 0));
}
#[test]
fn non_solid_object_does_not_block() {
// A non-solid object sits on empty floor: the cell stays passable.
let mut obj = ObjectDef::new(1, 0);
obj.solid = false;
let board = open_board(3, 1, (0, 0), vec![obj], &[]);
assert!(board.solid_at(1, 0).is_none());
assert!(board.is_passable(1, 0));
}
2026-06-06 14:11:20 -05:00
/// Stamps a crate cell onto an otherwise-open board.
fn crate_at(board: &mut Board, x: usize, y: usize) {
*board.get_mut(x, y) = (Archetype::Crate.default_glyph(), Archetype::Crate);
}
/// Stamps a wall cell onto an otherwise-open board.
fn wall_at(board: &mut Board, x: usize, y: usize) {
*board.get_mut(x, y) = (Archetype::Wall.default_glyph(), Archetype::Wall);
}
#[test]
fn in_bounds_checks_grid_boundaries() {
let board = open_board(3, 2, (0, 0), vec![], &[]);
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))); // x == width
assert!(!board.in_bounds((0, 2))); // y == height
}
#[test]
fn can_push_is_read_only_and_correct() {
// Crate with empty space beyond: pushable, and the board is left untouched.
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
assert!(board.can_push(1, 0, Direction::East));
assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation
assert_eq!(board.get(2, 0).1, Archetype::Empty);
// Crate against a wall: not pushable.
let mut board = open_board(3, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
assert!(!board.can_push(1, 0, Direction::East));
}
#[test]
fn player_pushes_single_crate() {
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0)); // player advanced onto crate's old cell
assert_eq!(b.get(1, 0).1, Archetype::Empty); // crate left this cell
assert_eq!(b.get(2, 0).1, Archetype::Crate); // crate moved one east
}
#[test]
fn push_blocked_by_wall_moves_nothing() {
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
wall_at(&mut board, 2, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); // blocked
assert_eq!(b.get(1, 0).1, Archetype::Crate); // crate didn't move
assert_eq!(b.get(2, 0).1, Archetype::Wall); // wall didn't move
}
#[test]
fn push_blocked_by_edge_moves_nothing() {
// Crate on the last column; player pushes it toward the board edge.
let mut board = open_board(2, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Crate);
}
#[test]
fn cascade_pushes_two_crates() {
let mut board = open_board(5, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
crate_at(&mut board, 2, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::Crate);
assert_eq!(b.get(3, 0).1, Archetype::Crate);
}
#[test]
fn cascade_blocked_by_wall_moves_nothing() {
let mut board = open_board(5, 1, (0, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
crate_at(&mut board, 2, 0);
wall_at(&mut board, 3, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(1, 0).1, Archetype::Crate);
assert_eq!(b.get(2, 0).1, Archetype::Crate);
}
#[test]
fn player_pushes_pushable_solid_object() {
// A solid, pushable object is shoved exactly like a crate.
let mut obj = ObjectDef::new(1, 0); // solid by default
obj.pushable = true;
let board = open_board(4, 1, (0, 0), vec![obj], &[]);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 0)); // object shoved east
}
#[test]
fn object_pushes_crate_on_init() {
// A scripted object moving east into a crate shoves it (move_object path).
let mut board = open_board(
4,
1,
(0, 0), // player behind the object, out of the push path
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); }")],
);
crate_at(&mut board, 2, 0);
let mut game = GameState::new(board);
game.run_init();
let b = game.board();
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 0)); // object advanced
assert_eq!(b.get(2, 0).1, Archetype::Empty); // crate left (2,0)
assert_eq!(b.get(3, 0).1, Archetype::Crate); // crate pushed to (3,0)
}
#[test]
fn pushable_allows_only_its_axis() {
use Direction::*;
for d in [North, South, East, West] {
assert!(!Pushable::No.allows(d));
assert!(Pushable::Any.allows(d));
}
assert!(Pushable::Horizontal.allows(East) && Pushable::Horizontal.allows(West));
assert!(!Pushable::Horizontal.allows(North) && !Pushable::Horizontal.allows(South));
assert!(Pushable::Vertical.allows(North) && Pushable::Vertical.allows(South));
assert!(!Pushable::Vertical.allows(East) && !Pushable::Vertical.allows(West));
}
/// Stamps an archetype cell onto an otherwise-open board.
fn stamp(board: &mut Board, x: usize, y: usize, arch: Archetype) {
*board.get_mut(x, y) = (arch.default_glyph(), arch);
}
#[test]
fn hcrate_pushes_east_but_not_north() {
// Pushing east: the HCrate slides.
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
stamp(&mut board, 1, 0, Archetype::HCrate);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(2, 0).1, Archetype::HCrate);
drop(b);
// Pushing north into an HCrate: blocked, nothing moves.
let mut board = open_board(1, 4, (0, 3), vec![], &[]);
stamp(&mut board, 0, 2, Archetype::HCrate);
let mut game = GameState::new(board);
game.try_move(Direction::North);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 3)); // blocked
assert_eq!(b.get(0, 2).1, Archetype::HCrate); // didn't move
}
#[test]
fn vcrate_pushes_north_but_not_east() {
// Pushing north: the VCrate slides.
let mut board = open_board(1, 4, (0, 3), vec![], &[]);
stamp(&mut board, 0, 2, Archetype::VCrate);
let mut game = GameState::new(board);
game.try_move(Direction::North);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 2));
assert_eq!(b.get(0, 2).1, Archetype::Empty);
assert_eq!(b.get(0, 1).1, Archetype::VCrate);
drop(b);
// Pushing east into a VCrate: blocked, nothing moves.
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
stamp(&mut board, 1, 0, Archetype::VCrate);
let mut game = GameState::new(board);
game.try_move(Direction::East);
let b = game.board();
assert_eq!((b.player.x, b.player.y), (0, 0)); // blocked
assert_eq!(b.get(1, 0).1, Archetype::VCrate); // didn't move
}
#[test]
fn fresh_board_is_valid_and_reports_errors() {
let mut board = open_board(1, 1, (0, 0), vec![], &[]);
assert!(board.is_valid());
board.report_error("something went wrong");
assert!(!board.is_valid());
assert_eq!(board.load_errors().len(), 1);
}
#[test]
fn directional_crate_default_glyph_tiles() {
assert_eq!(Archetype::HCrate.default_glyph().tile, 29);
assert_eq!(Archetype::VCrate.default_glyph().tile, 18);
}
2026-06-04 20:11:55 -05:00
#[test]
fn init_runs_only_on_run_init_not_at_construction() {
2026-06-04 23:52:33 -05:00
let board = board_with_object(
Some("greet"),
&[("greet", r#"fn init() { log("hello"); }"#)],
);
2026-06-04 20:11:55 -05:00
let mut game = GameState::new(board);
// init must not fire during construction / deserialization.
assert!(game.log.is_empty());
game.run_init();
assert_eq!(log_texts(&game), vec!["hello"]);
}
2026-06-03 23:21:05 -05:00
#[test]
2026-06-04 20:11:55 -05:00
fn tick_calls_script_tick_with_elapsed_seconds() {
let board = board_with_object(
Some("t"),
&[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)],
);
let mut game = GameState::new(board);
// No tick hook runs until tick() is called.
game.run_init();
assert!(game.log.is_empty());
2026-06-03 23:21:05 -05:00
game.tick(Duration::from_millis(500));
assert_eq!(game.log.len(), 1);
2026-06-04 20:11:55 -05:00
// The dt argument reached the script as ~0.5 seconds.
let logged: f64 = log_texts(&game)[0].parse().unwrap();
assert!((logged - 0.5).abs() < 1e-9);
}
2026-06-03 23:21:05 -05:00
2026-06-04 20:11:55 -05:00
#[test]
fn missing_hooks_and_no_script_are_noops() {
// Object with no script: nothing happens.
let mut game = GameState::new(board_with_object(None, &[]));
game.run_init();
game.tick(Duration::from_millis(33));
assert!(game.log.is_empty());
// Script defines neither init nor tick: also a no-op.
2026-06-04 23:52:33 -05:00
let mut game = GameState::new(board_with_object(
Some("e"),
&[("e", "fn other() { log(\"x\"); }")],
));
2026-06-04 20:11:55 -05:00
game.run_init();
game.tick(Duration::from_millis(33));
assert!(game.log.is_empty());
}
#[test]
fn compile_and_unknown_script_errors_are_logged() {
// A reference to a script name that isn't in the table.
let game = GameState::new(board_with_object(Some("ghost"), &[]));
assert!(log_texts(&game)[0].contains("unknown script 'ghost'"));
// A script that fails to compile is reported at construction time.
let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")]));
assert!(log_texts(&game)[0].contains("failed to compile"));
2026-06-03 23:21:05 -05:00
}
2026-06-04 23:52:33 -05:00
#[test]
fn script_reads_board_through_view() {
let board = open_board(
5,
3,
(3, 1),
vec![scripted_object(2, 1, "r")],
2026-06-05 01:09:50 -05:00
&[("r", "fn init() { log(Board.player_x.to_string()); }")],
2026-06-04 23:52:33 -05:00
);
let mut game = GameState::new(board);
game.run_init();
// The view reported the player's x (3).
assert_eq!(log_texts(&game), vec!["3"]);
}
#[test]
fn move_command_relocates_the_source_object() {
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(2, 1, "m")],
2026-06-05 01:09:50 -05:00
&[("m", "fn init() { move(East); }")],
2026-06-04 23:52:33 -05:00
);
let mut game = GameState::new(board);
game.run_init();
let b = game.board();
// East increments x by one; the object started at (2, 1).
assert_eq!((b.objects[0].x, b.objects[0].y), (3, 1));
}
#[test]
fn move_into_a_wall_or_edge_is_a_noop() {
// Object at the west edge moving west: out of bounds, ignored.
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(0, 1, "m")],
2026-06-05 01:09:50 -05:00
&[("m", "fn init() { move(West); }")],
2026-06-04 23:52:33 -05:00
);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].x, 0);
}
#[test]
fn set_tile_command_changes_the_source_glyph() {
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(2, 1, "s")],
&[("s", "fn init() { set_tile(7); }")],
);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].glyph.tile, 7);
}
#[test]
fn start_map_greeter_runs_init() {
// End-to-end against the shipped example map: load it, run init, and
// confirm the greeter read the board (interpolated message) and wrote to
// itself (set_tile). Also guards the example from drifting out of sync.
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml");
let board = crate::map_file::load(path).expect("load start.toml");
let mut game = GameState::new(board);
game.run_init();
assert!(
log_texts(&game)
.iter()
.any(|t| t.contains("hello from object")),
"greeter init should log a greeting"
);
assert!(
game.board().objects.iter().any(|o| o.glyph.tile == 2),
"greeter set_tile(2) should change its glyph"
);
}
#[test]
fn commands_are_routed_to_their_own_source_object() {
// Two objects with different scripts move in opposite directions; each
// must affect only itself (the per-call tag routes the command source).
// Targets are kept apart so the impassable objects don't block each other.
let board = open_board(
5,
3,
(0, 0),
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
&[
2026-06-05 01:09:50 -05:00
("e", "fn init() { move(East); }"),
("w", "fn init() { move(West); }"),
2026-06-04 23:52:33 -05:00
],
);
let mut game = GameState::new(board);
game.run_init();
let b = game.board();
assert_eq!(b.objects[0].x, 1); // moved east from 0
assert_eq!(b.objects[1].x, 3); // moved west from 4
}
2026-06-06 17:36:00 -05:00
#[test]
fn move_cost_rate_limits_repeated_moves() {
// init queues two moves; only the first resolves immediately, the second must
// wait the full 250 ms cooldown.
let board = open_board(
5,
1,
(0, 0), // player to the west, out of the object's path
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); move(East); }")],
);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].x, 2); // first move applied (1 -> 2)
// 200 ms of ticks: still inside the cooldown, no further movement.
game.tick(Duration::from_millis(100));
game.tick(Duration::from_millis(100));
assert_eq!(game.board().objects[0].x, 2);
// Crossing the 250 ms mark releases the queued second move.
game.tick(Duration::from_millis(100));
assert_eq!(game.board().objects[0].x, 3);
}
#[test]
fn blocked_move_still_costs_cooldown() {
// A move into a wall fails but still charges the 250 ms cooldown, so a second
// queued move (into open space) is delayed just as a successful move would be.
let mut board = open_board(
3,
3,
(0, 0),
vec![scripted_object(1, 1, "m")],
&[("m", "fn init() { move(East); move(South); }")],
);
wall_at(&mut board, 2, 1); // blocks the eastward move
let mut game = GameState::new(board);
game.run_init();
// First (eastward) move is blocked by the wall: object hasn't moved.
2026-06-06 18:49:45 -05:00
assert_eq!(
(game.board().objects[0].x, game.board().objects[0].y),
(1, 1)
);
2026-06-06 17:36:00 -05:00
// The blocked move charged the cooldown, so South is still pending here.
game.tick(Duration::from_millis(100));
game.tick(Duration::from_millis(100));
2026-06-06 18:49:45 -05:00
assert_eq!(
(game.board().objects[0].x, game.board().objects[0].y),
(1, 1)
);
2026-06-06 17:36:00 -05:00
// Past 250 ms the queued South move resolves.
game.tick(Duration::from_millis(100));
2026-06-06 18:49:45 -05:00
assert_eq!(
(game.board().objects[0].x, game.board().objects[0].y),
(1, 2)
);
2026-06-06 17:36:00 -05:00
}
#[test]
fn queue_length_reports_pending_actions() {
// Two zero-cost actions are queued before the length is read, then all three
// (incl. the log) drain in one pump.
let board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "q")],
2026-06-06 18:49:45 -05:00
&[(
"q",
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }",
)],
2026-06-06 17:36:00 -05:00
);
let mut game = GameState::new(board);
game.run_init();
assert!(log_texts(&game).iter().any(|t| t == "len=2"));
assert_eq!(game.board().objects[0].glyph.tile, 6); // last set_tile won
}
#[test]
fn queue_clear_drops_pending_actions() {
// clear() empties the output queue mid-script, so the queued moves never run.
let board = open_board(
5,
1,
(0, 0),
vec![scripted_object(1, 0, "c")],
&[("c", "fn init() { move(East); move(East); Queue.clear(); }")],
);
let mut game = GameState::new(board);
game.run_init();
game.tick(Duration::from_millis(300));
assert_eq!(game.board().objects[0].x, 1); // never moved
}
#[test]
fn blocked_reports_solid_and_clear() {
// Solid ahead (a wall): blocked() is true.
let mut board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "b")],
2026-06-06 18:49:45 -05:00
&[(
"b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
)],
2026-06-06 17:36:00 -05:00
);
wall_at(&mut board, 2, 0);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].glyph.tile, 9);
// Open ahead, nothing pending: blocked() is false.
let board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "b")],
2026-06-06 18:49:45 -05:00
&[(
"b",
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
)],
2026-06-06 17:36:00 -05:00
);
let mut game = GameState::new(board);
game.run_init();
assert_eq!(game.board().objects[0].glyph.tile, 7);
}
#[test]
fn blocked_sees_earlier_objects_pending_move() {
// obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked()
// toward that same cell and must see the pending move.
let board = open_board(
5,
3,
(0, 0),
vec![
scripted_object(1, 1, "mover"),
scripted_object(3, 1, "checker"),
],
&[
("mover", "fn tick(dt) { move(East); }"),
(
"checker",
"fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }",
),
],
);
let mut game = GameState::new(board);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 1)); // mover advanced
assert_eq!(b.objects[1].glyph.tile, 1); // checker saw the pending move
}
#[test]
fn collision_priority_resolves_in_array_order_and_bumps() {
// Two objects move into the same empty cell (1,0). obj0 (earlier) wins it;
// obj1 is blocked and bumps obj0. obj1 itself receives no bump.
let board = open_board(
3,
2,
(0, 1), // player off the contested row
vec![scripted_object(0, 0, "e"), scripted_object(2, 0, "w")],
&[
(
"e",
"fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }",
),
(
"w",
"fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }",
),
],
);
let mut game = GameState::new(board);
game.run_init();
// The bump fires during resolution but its log is emitted into obj0's queue;
// a later tick (past both objects' move cooldown) flushes it to the game log.
game.tick(Duration::from_millis(300));
{
let b = game.board();
assert_eq!((b.objects[0].x, b.objects[0].y), (1, 0)); // obj0 won the cell
assert_eq!((b.objects[1].x, b.objects[1].y), (2, 0)); // obj1 blocked
}
let logs = log_texts(&game);
assert!(logs.iter().any(|t| t == "o0 by 1")); // obj0 bumped by obj1
assert!(!logs.iter().any(|t| t.starts_with("o1 by"))); // obj1 not bumped
}
#[test]
fn player_bump_fires_with_negative_one() {
// The player walks into a solid scripted object; the object is bumped with -1
// and the player does not move onto it.
let board = open_board(
3,
1,
(0, 0),
vec![scripted_object(1, 0, "b")],
&[("b", "fn bump(id) { log(`bumped by ${id}`); }")],
);
let mut game = GameState::new(board);
game.run_init();
game.try_move(Direction::East);
assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked
// bump's log is emitted into the object's queue; a tick flushes it.
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "bumped by -1"));
}
2026-06-06 18:49:45 -05:00
#[test]
fn display_glyph_uses_floor_for_empty_and_grid_for_solid() {
// A board with a distinctive floor glyph; empty cells show the floor, while
// a wall keeps drawing its own grid glyph (floor ignored under solids).
let mut board = open_board(2, 1, (1, 0), vec![], &[]);
let floor_glyph = Glyph {
tile: '.' as u32,
fg: Rgba8 {
r: 10,
g: 20,
b: 30,
a: 255,
},
bg: Rgba8 {
r: 1,
g: 2,
b: 3,
a: 255,
},
};
board.floor = vec![floor_glyph; 2];
wall_at(&mut board, 0, 0);
assert_eq!(board.display_glyph(0, 0), Archetype::Wall.default_glyph()); // solid: grid glyph
assert_eq!(board.display_glyph(1, 0), floor_glyph); // empty: floor glyph
}
#[test]
fn pushing_a_crate_reveals_the_floor_underneath() {
// The cell a crate is pushed off of becomes Empty, so its `display_glyph`
// should show the floor glyph rather than black-on-black.
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
let floor_glyph = Glyph {
tile: ',' as u32,
fg: Rgba8 {
r: 40,
g: 60,
b: 40,
a: 255,
},
bg: Rgba8 {
r: 5,
g: 10,
b: 5,
a: 255,
},
};
board.floor = vec![floor_glyph; 4];
crate_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.try_move(Direction::East); // player at (0,0) pushes the crate east
let b = game.board();
assert_eq!(b.get(2, 0).1, Archetype::Crate); // crate moved one east
// The vacated cell (1,0) is now Empty and reveals the floor.
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.display_glyph(1, 0), floor_glyph);
}
2026-06-06 17:36:00 -05:00
#[test]
fn solid_at_reports_player() {
// The player's own cell is solid (and thus impassable to movers).
let board = open_board(3, 1, (1, 0), vec![], &[]);
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 push_into_player_pushes_player() {
// Crate shoved east into the player slides the player along into open space.
let mut board = open_board(4, 1, (2, 0), vec![], &[]);
crate_at(&mut board, 1, 0);
assert!(board.can_push(1, 0, Direction::East));
board.push(1, 0, Direction::East);
assert_eq!(board.get(1, 0).1, Archetype::Empty); // crate left its cell
assert_eq!(board.get(2, 0).1, Archetype::Crate); // crate took the player's old cell
assert_eq!((board.player.x, board.player.y), (3, 0)); // player shoved east
}
#[test]
fn push_into_player_blocked_by_wall() {
// Player backed against a wall: the push has nowhere to go, so nothing moves
// and the player is never overlapped.
let mut board = open_board(4, 1, (2, 0), vec![], &[]);
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
assert_eq!(board.get(1, 0).1, Archetype::Crate);
assert_eq!((board.player.x, board.player.y), (2, 0));
}
#[test]
fn object_push_into_player() {
// A scripted object moving into the player pushes the player when there's room.
let board = open_board(
5,
1,
(2, 0),
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); }")],
);
let mut game = GameState::new(board);
game.run_init();
{
let b = game.board();
assert_eq!((b.objects[0].x, b.objects[0].y), (2, 0)); // object took player's cell
assert_eq!((b.player.x, b.player.y), (3, 0)); // player shoved east
}
// With a wall behind the player, the object is blocked and nothing moves.
let mut board = open_board(
4,
1,
(2, 0),
vec![scripted_object(1, 0, "m")],
&[("m", "fn init() { move(East); }")],
);
wall_at(&mut board, 3, 0);
let mut game = GameState::new(board);
game.run_init();
let b = game.board();
assert_eq!((b.objects[0].x, b.objects[0].y), (1, 0)); // object blocked
assert_eq!((b.player.x, b.player.y), (2, 0)); // player not pushed
}
2026-06-03 23:21:05 -05:00
}