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

645 lines
28 KiB
Rust
Raw Normal View History

2026-06-09 22:38:17 -05:00
use crate::action::Action;
2026-06-13 01:25:58 -05:00
use crate::board::Board;
2026-06-03 22:46:54 -05:00
use crate::log::LogLine;
2026-06-08 22:15:44 -05:00
use crate::script::ScriptHost;
2026-06-15 23:35:18 -05:00
use crate::utils::{Direction, ObjectId, Player, ScriptArg, Solid};
2026-06-13 01:25:58 -05:00
use crate::world::World;
use std::cell::{Ref, RefMut};
2026-06-04 20:11:55 -05:00
use std::time::Duration;
2026-06-08 22:15:44 -05:00
/// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0;
2026-06-10 01:42:33 -05:00
// Re-export ScrollLine so kiln-tui can pattern-match scroll content without
// accessing the private `action` module directly.
pub use crate::action::ScrollLine;
/// An active scroll overlay opened by a scripted object via `scroll()`.
///
2026-06-15 09:15:30 -05:00
/// While a `Scroll` is present on [`GameState`], the front-end should display
/// the overlay and pause ticks. When the player selects a choice (or dismisses),
/// the front-end sets [`choice`](Scroll::choice); [`GameState::tick`] calls
/// [`GameState::handle_scroll`] at the start of each tick to dispatch and clear it.
2026-06-10 01:42:33 -05:00
pub struct Scroll {
/// The object whose `scroll()` call opened this overlay.
pub source: ObjectId,
/// The lines of content to display.
pub lines: Vec<ScrollLine>,
2026-06-15 09:15:30 -05:00
/// The choice key the player selected, or `None` if dismissed without a choice.
/// Set by the front-end; consumed by [`GameState::handle_scroll`].
pub choice: Option<String>,
2026-06-10 01:42:33 -05:00
}
2026-06-08 22:15:44 -05:00
/// An active speech bubble emitted by a scripted object via `say()`.
pub struct SpeechBubble {
/// The object whose `say()` call created this bubble.
pub object_id: ObjectId,
/// The text to display.
pub text: String,
/// Seconds remaining before the bubble disappears.
pub remaining: f64,
}
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-13 01:25:58 -05:00
/// game data. It owns a [`World`] (all boards + world scripts) and tracks which
/// board is currently active. Front-ends reach the active board through
/// [`board`](GameState::board) / [`board_mut`](GameState::board_mut). Scripts
/// read the board and queue mutations via commands applied by `resolve` after
/// each script batch.
pub struct GameState {
2026-06-13 01:25:58 -05:00
/// All boards and world-level scripts.
world: World,
/// Key of the currently active board in [`world.boards`](World::boards).
current_board_name: String,
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-13 01:25:58 -05:00
/// The Rhai scripting runtime for the active board's objects.
2026-06-04 20:11:55 -05:00
scripts: ScriptHost,
2026-06-08 22:15:44 -05:00
/// Active speech bubbles from `say()` calls, displayed by the front-end over the board.
pub speech_bubbles: Vec<SpeechBubble>,
2026-06-10 01:42:33 -05:00
/// An active scroll overlay opened by `scroll()`. `Some` while the overlay is
2026-06-15 09:15:30 -05:00
/// visible; the front-end pauses ticks and sets [`Scroll::choice`] before resuming.
2026-06-10 01:42:33 -05:00
pub active_scroll: Option<Scroll>,
2026-06-13 16:24:29 -05:00
/// Remaining seconds for the board-entry transition animation, set to `1.0`
/// by [`enter_board`](GameState::enter_board). Front-ends tick this down and
/// may block input or show a visual effect while it is `Some(t)` where `t > 0`.
pub board_transition: Option<f64>,
2026-06-21 12:32:09 -05:00
/// The player's current health. Game-global (not per-board), so it persists
/// across board transitions. Players start with `5`. There is no runtime
/// mutation path yet — front-ends only display it.
pub player_health: u32,
/// The number of gems the player has collected. Game-global like
/// [`player_health`](GameState::player_health); starts at `0` and is
/// display-only for now.
pub player_gems: u32,
}
impl GameState {
2026-06-13 01:25:58 -05:00
/// Creates a [`GameState`] from a loaded [`World`], starting on `world.start`.
2026-06-04 20:11:55 -05:00
///
2026-06-13 01:25:58 -05:00
/// The starting board's objects are compiled and registered with the
/// [`ScriptHost`] using `world.scripts`. No lifecycle hooks are run here;
/// call [`run_init`](GameState::run_init) once the game is ready to start.
/// Compile errors are surfaced into the log immediately.
pub fn from_world(world: World) -> Self {
let name = world.start.clone();
let host = ScriptHost::new(
2026-06-15 23:35:18 -05:00
world
.boards
.get(&name)
.expect("world::load guarantees start board exists"),
2026-06-13 01:25:58 -05:00
&world.scripts,
);
2026-06-04 20:11:55 -05:00
let mut state = Self {
2026-06-13 01:25:58 -05:00
world,
current_board_name: name,
2026-06-03 22:46:54 -05:00
log: Vec::new(),
2026-06-13 01:25:58 -05:00
scripts: host,
2026-06-08 22:15:44 -05:00
speech_bubbles: Vec::new(),
2026-06-10 01:42:33 -05:00
active_scroll: None,
2026-06-13 16:24:29 -05:00
board_transition: None,
2026-06-21 12:32:09 -05:00
player_health: 5,
player_gems: 0,
2026-06-04 20:11:55 -05:00
};
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-13 01:25:58 -05:00
/// Creates a `GameState` from a single [`Board`] with no scripts.
///
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
#[cfg(test)]
pub fn new(board: Board) -> Self {
Self::with_scripts(board, std::collections::HashMap::new())
}
/// Creates a `GameState` from a single [`Board`] and an explicit script pool.
///
/// Test-only convenience. Use [`from_world`](GameState::from_world) in production.
#[cfg(test)]
pub fn with_scripts(board: Board, scripts: std::collections::HashMap<String, String>) -> Self {
// Wrap the bare board in Rc<RefCell<Board>> to match World::boards storage.
let board_ref = std::rc::Rc::new(std::cell::RefCell::new(board));
let world = World {
name: String::new(),
start: "board".to_string(),
scripts,
boards: std::collections::HashMap::from([("board".to_string(), board_ref)]),
};
Self::from_world(world)
}
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-13 01:25:58 -05:00
self.world.boards[&self.current_board_name].borrow()
2026-06-04 23:52:33 -05:00
}
/// Borrows the active board for mutation.
pub fn board_mut(&self) -> RefMut<'_, Board> {
2026-06-13 01:25:58 -05:00
self.world.boards[&self.current_board_name].borrow_mut()
}
/// The name (key) of the currently active board within the world.
pub fn current_board_name(&self) -> &str {
&self.current_board_name
2026-06-04 23:52:33 -05:00
}
2026-06-13 18:43:29 -05:00
/// Returns a clone of the `Rc` for the active board.
///
/// Lets a front-end hold a reference to the current board across a board
/// transition — the old board stays alive in `world.boards`, so the clone
/// keeps it reachable even after `current_board_name` changes.
pub fn board_rc(&self) -> std::rc::Rc<std::cell::RefCell<Board>> {
self.world.boards[&self.current_board_name].clone()
}
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).
2026-06-15 09:15:30 -05:00
/// Called once per frame by the front-end's game loop. First processes any active
/// scroll (dispatching the player's choice if set), then drives object tick hooks
/// and resolves queued actions.
2026-06-03 23:21:05 -05:00
pub fn tick(&mut self, dt: Duration) {
2026-06-15 09:15:30 -05:00
// Process any pending scroll choice before running scripts; ticks are
// suppressed by the front-end while a scroll overlay is visible, so
// this runs exactly once per player interaction with a scroll.
self.handle_scroll();
2026-06-06 17:36:00 -05:00
let secs = dt.as_secs_f64();
self.scripts.run_tick(secs);
2026-06-08 22:15:44 -05:00
// Expire speech bubbles before resolving new actions so a fresh say()
// this frame isn't immediately culled.
2026-06-15 23:35:18 -05:00
self.speech_bubbles.retain_mut(|b| {
b.remaining -= secs;
b.remaining > 0.0
});
2026-06-06 17:36:00 -05:00
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
2026-06-09 22:38:17 -05:00
/// the `bump` and `send` hooks the moves triggered.
2026-06-06 17:36:00 -05:00
///
2026-06-09 22:38:17 -05:00
/// Done in two phases so no `board_mut` borrow is held while scripts run
2026-06-06 17:36:00 -05:00
/// (they read the board through its getters): phase A mutates the board and records
2026-06-09 22:38:17 -05:00
/// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B fires the
/// hooks after the borrow drops.
2026-06-06 17:36:00 -05:00
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();
2026-06-06 20:01:07 -05:00
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
2026-06-09 22:38:17 -05:00
let mut sends: Vec<(ObjectId, String, Option<ScriptArg>)> = Vec::new();
2026-06-08 22:15:44 -05:00
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
2026-06-10 01:42:33 -05:00
// Collected outside the board borrow so we can assign to self.active_scroll.
let mut new_scroll: Option<Scroll> = None;
2026-06-06 17:36:00 -05:00
{
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) => {
2026-06-06 20:01:07 -05:00
if let Some(obj) = board.objects.get_mut(&ba.source) {
2026-06-06 17:36:00 -05:00
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-15 23:35:18 -05:00
Action::SetTag {
target,
tag,
present,
} => {
2026-06-07 01:26:18 -05:00
if let Some(obj) = board.objects.get_mut(&target) {
2026-06-15 23:35:18 -05:00
if present {
obj.tags.insert(tag);
} else {
obj.tags.remove(&tag);
}
2026-06-07 01:26:18 -05:00
}
}
2026-06-08 22:15:44 -05:00
// Replace any existing bubble from this object so repeated say() calls
// don't stack visually — the new text resets the timer.
2026-06-13 15:33:04 -05:00
Action::Say(text, duration) => new_bubbles.push(SpeechBubble {
2026-06-08 22:15:44 -05:00
object_id: ba.source,
text,
2026-06-13 15:33:04 -05:00
remaining: duration,
2026-06-08 22:15:44 -05:00
}),
// Delays are consumed by ScriptHost::drain and never reach the board queue.
Action::Delay(_) => {}
2026-06-09 22:38:17 -05:00
Action::SetColor { fg, bg } => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
2026-06-15 23:35:18 -05:00
if let Some(c) = fg {
obj.glyph.fg = c;
}
if let Some(c) = bg {
obj.glyph.bg = c;
}
2026-06-09 22:38:17 -05:00
}
}
// Collected and fired after the board borrow drops, like bumps.
2026-06-15 23:35:18 -05:00
Action::Send {
target,
fn_name,
arg,
} => {
2026-06-09 22:38:17 -05:00
sends.push((target, fn_name, arg));
}
2026-06-10 01:42:33 -05:00
// Later scrolls overwrite earlier ones from the same tick.
Action::Scroll(lines) => {
2026-06-15 23:35:18 -05:00
new_scroll = Some(Scroll {
source: ba.source,
lines,
choice: None,
});
2026-06-10 01:42:33 -05:00
}
2026-06-13 17:58:04 -05:00
Action::Teleport { x, y } => {
if !board.in_bounds((x, y)) {
2026-06-15 23:35:18 -05:00
logs.push(LogLine::error(format!("teleport({x},{y}): out of bounds")));
2026-06-13 17:58:04 -05:00
} else {
let (ux, uy) = (x as usize, y as usize);
2026-06-15 23:35:18 -05:00
let source_solid = board
.objects
.get(&ba.source)
2026-06-13 17:58:04 -05:00
.map(|o| o.solid)
.unwrap_or(false);
if source_solid && board.solid_at(ux, uy).is_some() {
logs.push(LogLine::error(format!(
"teleport({x},{y}): destination is solid"
)));
} else if let Some(obj) = board.objects.get_mut(&ba.source) {
obj.x = ux;
obj.y = uy;
}
}
}
2026-06-21 01:32:47 -05:00
// push() self-checks can_push, so an in-bounds guard is all we add.
Action::Push { x, y, dir } => {
if board.in_bounds((x, y)) {
board.push(x as usize, y as usize, dir);
} else {
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
}
}
// apply_swap reads all sources then writes all destinations, so a
// whole batch of moves resolves simultaneously (cycles included).
Action::Swap(pairs) => logs.extend(board.apply_swap(&pairs)),
2026-06-04 23:52:33 -05:00
}
}
}
2026-06-06 17:36:00 -05:00
self.log.extend(logs);
2026-06-08 22:15:44 -05:00
for bubble in new_bubbles {
// One bubble per object: replace the existing one if present.
2026-06-15 23:35:18 -05:00
self.speech_bubbles
.retain(|b| b.object_id != bubble.object_id);
2026-06-08 22:15:44 -05:00
self.speech_bubbles.push(bubble);
}
2026-06-10 01:42:33 -05:00
if let Some(scroll) = new_scroll {
self.active_scroll = Some(scroll);
}
2026-06-06 17:36:00 -05:00
for (bumped, bumper) in bumps {
self.scripts.run_bump(bumped, bumper);
2026-06-04 23:52:33 -05:00
}
2026-06-09 22:38:17 -05:00
for (target, fn_name, arg) in sends {
self.scripts.run_send(target, &fn_name, arg);
}
2026-06-06 17:36:00 -05:00
self.drain_errors();
2026-06-03 23:21:05 -05:00
}
2026-06-15 09:15:30 -05:00
/// Consumes the active scroll, dispatching the player's choice (if any) back
/// to the source object via `send`.
2026-06-10 01:42:33 -05:00
///
2026-06-15 09:15:30 -05:00
/// Called automatically by [`tick`](GameState::tick) as its first step. The
/// front-end sets [`Scroll::choice`] before resuming ticks; if no choice was
/// made (player dismissed), `choice` stays `None` and the scroll is cleared
/// without dispatching.
pub fn handle_scroll(&mut self) {
if let Some(scroll) = self.active_scroll.take()
&& let Some(choice) = scroll.choice
{
self.scripts.run_send(scroll.source, &choice, None);
2026-06-10 01:42:33 -05:00
self.drain_errors();
}
}
2026-06-13 16:24:29 -05:00
/// Switches the active board, placing the player at the named arrival portal,
/// rebuilding the script host, and running `init()` hooks on the new board's objects.
///
/// Called automatically by [`try_move`](GameState::try_move) when the player
/// steps onto a portal. Front-ends may check [`board_transition`](GameState::board_transition)
/// to play a visual effect during the switch.
pub fn enter_board(&mut self, target_map: &str, target_entry: &str) {
if !self.world.boards.contains_key(target_map) {
2026-06-15 23:35:18 -05:00
self.log.push(LogLine::error(format!(
"portal target board {target_map:?} not found"
)));
2026-06-13 16:24:29 -05:00
return;
}
// Find the named arrival portal on the target board (borrow then release).
2026-06-15 23:35:18 -05:00
let arrival = self.world.boards[target_map]
.borrow()
.portals
.iter()
2026-06-13 16:24:29 -05:00
.find(|p| p.name == target_entry)
.map(|p| (p.x, p.y));
let (ax, ay) = match arrival {
Some(pos) => pos,
None => {
self.log.push(LogLine::error(format!(
"portal entry {target_entry:?} not found on board {target_map:?}"
)));
return;
}
};
// Clear per-board transient state.
self.speech_bubbles.clear();
self.active_scroll = None;
// Switch to the new board and place the player at the arrival portal.
self.current_board_name = target_map.to_string();
2026-06-15 23:35:18 -05:00
self.board_mut().player = Player {
x: ax as i32,
y: ay as i32,
};
2026-06-13 16:24:29 -05:00
// Rebuild the script host for the new board's objects.
self.scripts = ScriptHost::new(
&self.world.boards[&self.current_board_name],
&self.world.scripts,
);
// Stub hook for the front-end transition animation (1 second).
self.board_transition = Some(1.0);
// Run init hooks and resolve their queued actions.
self.run_init();
}
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;
2026-06-13 16:24:29 -05:00
let portal_target;
2026-06-06 17:36:00 -05:00
{
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) {
2026-06-06 20:01:07 -05:00
Some(Solid::Object(_)) => board.solid_object_id_at(nx, ny),
2026-06-06 17:36:00 -05:00
_ => 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-13 16:24:29 -05:00
// Check for a portal at the new position; clone strings to release the borrow.
2026-06-15 23:35:18 -05:00
portal_target = board
.portals
.iter()
2026-06-13 16:24:29 -05:00
.find(|p| p.x == nx && p.y == ny)
.map(|p| (p.target_map.clone(), p.target_entry.clone()));
} else {
portal_target = None;
2026-06-06 17:36:00 -05:00
}
2026-06-06 14:11:20 -05:00
}
2026-06-13 16:24:29 -05:00
// A portal takes priority: board transitions skip the bump hook.
if let Some((target_map, target_entry)) = portal_target {
self.enter_board(&target_map, &target_entry);
return;
}
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 20:01:07 -05:00
/// Moves object `id` one cell in `dir` on `board`, returning the [`ObjectId`] of a
/// solid object it bumped (its target cell was occupied by that object), if any.
2026-06-06 17:36:00 -05:00
///
/// 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.
2026-06-06 20:01:07 -05:00
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option<ObjectId> {
2026-06-06 17:36:00 -05:00
let (dx, dy): (i32, i32) = dir.into();
2026-06-06 20:01:07 -05:00
let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?;
2026-06-06 17:36:00 -05:00
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);
2026-06-06 20:01:07 -05:00
// Capture the bumped object before any push relocates it (its id is stable).
2026-06-06 17:36:00 -05:00
let bumped = match board.solid_at(nx, ny) {
2026-06-06 20:01:07 -05:00
Some(Solid::Object(_)) => board.solid_object_id_at(nx, ny),
2026-06-06 17:36:00 -05:00
_ => 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
2026-06-06 20:01:07 -05:00
let obj = board.objects.get_mut(&id).expect("id checked above");
2026-06-06 17:36:00 -05:00
obj.x = nx;
obj.y = ny;
}
bumped
}
2026-06-21 01:32:47 -05:00
#[cfg(test)]
mod tests {
use super::GameState;
use crate::archetype::Archetype;
use crate::board::tests::{crate_at, open_board, wall_at};
use crate::object_def::ObjectDef;
use std::collections::HashMap;
use std::time::Duration;
/// Builds a `GameState` with one non-solid object running `src` as its script.
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.script_name = Some("s".to_string());
let mut board = open_board(board_w, 1, (board_w as i32 - 1, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game
}
#[test]
fn script_push_shoves_a_crate() {
// The object pushes the crate at (2,0) one step east on its first tick.
let mut game = game_with_object_script(
5,
"fn tick(dt) { if Queue.length() == 0 { push(2, 0, East); } }",
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
}
#[test]
fn script_swap_moves_crates_simultaneously() {
// Swap the crate at (2,0) with the empty cell at (3,0): one ends up empty,
// the other holds the crate, applied in a single batch.
let mut game = game_with_object_script(
5,
"fn tick(dt) { if Queue.length() == 0 { swap([[2, 0, 3, 0]]); } }",
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
}
#[test]
fn script_passable_distinguishes_empty_solid_and_offboard() {
// (1,0) empty, (2,0) a solid crate, (9,0) off the 4-wide board. The non-solid
// object sits at (0,0); the player at (3,0). passable should report only the
// genuinely empty in-bounds cell.
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.script_name = Some("s".to_string());
let mut board = open_board(4, 1, (3, 0), vec![obj]);
crate_at(&mut board, 2, 0);
let src = "fn init() { \
log(if passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if passable(9, 0) { \"off:yes\" } else { \"off:no\" }); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
let lines: Vec<String> = game
.log
.iter()
.map(|l| l.spans.first().map(|s| s.text.clone()).unwrap_or_default())
.collect();
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]);
}
/// Builds a 3×3 board with a non-solid clockwise spinner object (running the
/// real `scripts/spinner.rhai`) at the centre and the player parked on it.
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
spinner_board_dir(crates, walls, false)
}
/// Like [`spinner_board`] but `ccw` attaches the `BUILTIN_spinner_ccw` tag so the
/// script spins counter-clockwise (clockwise is the default when no tag present).
fn spinner_board_dir(
crates: &[(usize, usize)],
walls: &[(usize, usize)],
ccw: bool,
) -> GameState {
let mut obj = ObjectDef::new(1, 1);
obj.solid = false;
obj.script_name = Some("spinner".to_string());
if ccw {
obj.tags.insert("BUILTIN_spinner_ccw".to_string());
}
let mut board = open_board(3, 3, (1, 1), vec![obj]);
for &(x, y) in crates {
crate_at(&mut board, x, y);
}
for &(x, y) in walls {
wall_at(&mut board, x, y);
}
let scripts = HashMap::from([(
"spinner".to_string(),
include_str!("scripts/spinner.rhai").to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
game
}
#[test]
fn spinner_does_not_destroy_a_blocked_neighbour() {
// Crates at N(1,0) and NE(2,0), a wall at E(2,1). NE can't rotate into the
// wall, so N must not rotate onto NE — the old (can_shift-only) script
// overwrote and destroyed NE's crate. The cascade leaves everything put.
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N kept
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE kept (not destroyed)
assert_eq!(b.get(0, 2, 1).1, Archetype::Wall); // wall kept
}
#[test]
fn spinner_rotates_an_arc_into_a_hole() {
// An arc W(0,1) -> NW(0,0) -> N(1,0) draining into the hole at NE(2,0).
// Clockwise, every crate advances one slot and the hole ends up at W.
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE: filled by N
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N: filled by NW
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: filled by W
assert_eq!(b.get(0, 0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
}
#[test]
fn spinner_ccw_rotates_the_other_way() {
// A lone crate at N(1,0) with both diagonal holes open. Clockwise it would
// go to NE(2,0); counter-clockwise it goes to NW(0,0) instead.
let mut game = spinner_board_dir(&[(1, 0)], &[], true);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); // NE: untouched
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); // N: vacated
}
#[test]
fn spinner_animates_its_glyph_without_recoloring() {
// The glyph character cycles '/'(47) '─'(0xC4) '\'(92) '│'(0xB3) one frame
// per 0.5s rotation, while the colours stay put.
let mut game = spinner_board(&[], &[]);
let id = *game.board().objects.keys().next().unwrap();
let (fg0, bg0) = {
let b = game.board();
(b.objects[&id].glyph.fg, b.objects[&id].glyph.bg)
};
let mut seq = Vec::new();
for _ in 0..5 {
game.tick(Duration::from_secs_f64(0.5));
let b = game.board();
seq.push(b.objects[&id].glyph.tile);
assert_eq!(b.objects[&id].glyph.fg, fg0, "fg unchanged");
assert_eq!(b.objects[&id].glyph.bg, bg0, "bg unchanged");
}
assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]);
}
}