Files
kiln/kiln-core/src/game.rs
T
2026-07-26 23:29:43 -05:00

776 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::action::{apply_push, apply_shift, apply_teleport, Action, BoardAction, SendArg};
use crate::board::Board;
use crate::log::LogLine;
use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId};
use crate::world::World;
use std::cell::{Ref, RefMut};
use std::collections::{BTreeSet, HashSet, VecDeque};
use std::hash::Hash;
use std::time::Duration;
/// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0;
// Re-export ScrollLine so kiln-tui can pattern-match scroll content without
// accessing the private `action` module directly.
pub use crate::action::ScrollLine;
use crate::player::{Player, PlayerRef};
use crate::portal::Portal;
use crate::tile::{EnterResponse, Tile};
/// An active scroll overlay opened by a scripted object via `scroll()`.
///
/// 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.
pub struct Scroll {
/// The object whose `scroll()` call opened this overlay.
pub source: ObjectId,
/// The lines of content to display.
pub lines: Vec<ScrollLine>,
/// 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>,
}
/// 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,
}
/// Holds the active game world and provides game-logic operations.
///
/// `GameState` is the boundary between the engine (rendering, input) and the
/// 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 {
/// All boards and world-level scripts.
world: World,
/// Key of the currently active board in [`world.boards`](World::boards).
current_board_name: String,
/// The in-game message log, oldest first (newest pushed at the end).
pub log: Vec<LogLine>,
/// The Rhai scripting runtime for the active board's objects.
scripts: ScriptHost,
/// Active speech bubbles from `say()` calls, displayed by the front-end over the board.
pub speech_bubbles: Vec<SpeechBubble>,
/// An active scroll overlay opened by `scroll()`. `Some` while the overlay is
/// visible; the front-end pauses ticks and sets [`Scroll::choice`] before resuming.
pub active_scroll: Option<Scroll>,
/// 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>,
/// The game-global player state (health, gems, keys) — see [`Player`]. Not
/// per-board: it persists across board transitions, unlike the per-board
/// position in [`Board::player`](crate::board::Board::player). Scripts mutate
/// it via `alter_gems`/`alter_health`/`set_key` and read a snapshot of it.
pub player: PlayerRef,
}
impl GameState {
/// Creates a [`GameState`] from a loaded [`World`], starting on `world.start`.
///
/// 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 player = Player::new_ref();
let host = ScriptHost::new(
world
.boards
.get(&name)
.expect("world::load guarantees start board exists").clone(),
player.clone(),
&world.scripts,
);
let mut state = Self {
world,
current_board_name: name,
log: Vec::new(),
scripts: host,
speech_bubbles: Vec::new(),
active_scroll: None,
board_transition: None,
player
};
state.drain_log();
state
}
/// 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(),
torch: crate::fov::SIGHT_RADIUS as u32,
scripts,
boards: std::collections::HashMap::from([("board".to_string(), board_ref)]),
};
Self::from_world(world)
}
/// Borrows the active board for reading (e.g. by a front-end renderer).
pub fn board(&self) -> Ref<'_, Board> {
self.world.boards[&self.current_board_name].borrow()
}
/// Borrows the active board for mutation.
pub fn board_mut(&self) -> RefMut<'_, Board> {
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
}
/// The player's torch radius (world-wide config; see [`World::torch`]),
/// passed to [`Board::lighting`] when rendering a dark board.
pub fn torch(&self) -> u32 {
self.world.torch
}
/// 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()
}
/// Appends a styled message to the log.
pub fn log(&mut self, line: LogLine) {
self.log.push(line);
}
/// 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.
pub fn run_init(&mut self) {
// Run each object's init hook in ascending id order, applying its actions
// immediately so a later object's init sees what an earlier one did.
let ids = self.board().all_ids();
for id in ids {
let actions = self.scripts.run_init_on(id);
self.apply_actions(actions);
}
}
/// 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. First processes any active
/// scroll (dispatching the player's choice if set), then drives object tick hooks
/// and resolves queued actions.
pub fn tick(&mut self, dt: Duration) {
// 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();
let secs = dt.as_secs_f64();
// Expire speech bubbles once per frame, before applying new actions so a
// fresh say() this frame isn't immediately culled.
self.speech_bubbles.retain_mut(|b| {
b.remaining -= secs;
b.remaining > 0.0
});
// Run each object's tick in ascending id order, applying its drained
// actions immediately so the next object sees the updated board.
let ids = self.board().all_ids();
for id in ids {
let actions = self.scripts.run_tick_on(id, secs);
self.apply_actions(actions);
}
}
/// Drains the log lines collected by the script host — script `log()` output
/// plus engine/runtime errors — into the game log.
fn drain_log(&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 lines = self.scripts.take_logs();
self.log.extend(lines);
}
/// Applies one object's drained `actions` to the board
fn apply_actions(&mut self, actions: Vec<BoardAction>) {
// Application-time errors (teleport/push/shift failures) go straight onto
// the shared LogSink — the same immediate channel as script `log()` output,
// so everything lands in the log in one emission order and is flushed by
// `drain_log`. A cheap Rc clone lets us push while the board borrow (which
// also borrows `self`) is held.
let log_sink = self.scripts.log_sink().clone();
for ba in actions {
match ba.action {
Action::Move(dir) => {
step_object(&mut self.board_mut(), ba.source, dir);
}
Action::SetTile(tile) => {
if let Some(scr) = self.board_mut().scripting_mut(ba.source) {
scr.glyph.tile = tile;
}
}
Action::SetLight(radius) => {
if let Some(obj) = self.board_mut().scripting_mut(ba.source) {
obj.optics.glow = radius;
}
}
Action::SetTag {
target,
tag,
present,
} => {
if let Some(obj) = self.board_mut().scripting_mut(target) {
if present {
obj.tags.insert(tag);
} else {
obj.tags.remove(&tag);
}
}
}
// Replace any existing bubble from this object so repeated say() calls
// don't stack visually — the new text resets the timer.
Action::Say(text, duration) => {
// One bubble per object: replace the existing one if present.
self.speech_bubbles
.retain(|b| b.object_id != ba.source);
self.speech_bubbles.push(
SpeechBubble {
object_id: ba.source,
text,
remaining: duration,
}
);
},
// Delays are consumed by ScriptHost::drain and never reach the board queue.
Action::Delay(_) => {}
Action::SetColor { fg, bg } => {
if let Some(obj) = self.board_mut().scripting_mut(ba.source) {
if let Some(c) = fg {
obj.glyph.fg = c;
}
if let Some(c) = bg {
obj.glyph.bg = c;
}
}
}
Action::Send { target, fn_name, arg} => {
self.scripts.run_send(target, &fn_name, arg);
}
Action::Scroll(lines) => {
self.active_scroll.replace(Scroll {
source: ba.source,
lines,
choice: None,
});
}
Action::Teleport { target, x, y } => {
apply_teleport(&mut self.board_mut(), target, x, y).unwrap_or_else(|e| log_sink.error(e))
}
Action::Push { x, y, dir } => {
apply_push(&mut self.board_mut(), x, y, dir).unwrap_or_else(|e| log_sink.error(e))
}
Action::Shift(cells) => {
apply_shift(&mut self.board_mut(), &cells).unwrap_or_else(|e| log_sink.error(e))
}
Action::AddGems(n) => {
self.player.borrow_mut().alter_gems(n);
},
Action::AlterHealth(dh) => {
self.player.borrow_mut().alter_health(dh)
},
Action::SetKey(color, present) => {
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
log_sink.error(format!("set_key: unknown color {color:?}"));
}
},
Action::Die => {
self.board_mut().remove_object(ba.source);
}
}
}
self.drain_log();
}
/// Consumes the active scroll, dispatching the player's choice (if any) back
/// to the source object via `send`.
///
/// 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
{
// Dispatch the choice back to the source object and apply whatever it
// queues (plus any bump/send cascade), the same as a tick.
self.scripts.run_send(scroll.source, &choice, SendArg::None);
}
}
/// 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) {
self.log.push(LogLine::error(format!(
"portal target board {target_map:?} not found"
)));
return;
}
// Find the named arrival portal on the target board (borrow then release).
let arrival = self.world.boards[target_map]
.borrow()
.named_portal(target_entry).map(Portal::location);
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
self.current_board_name = target_map.to_string();
// Clear any player instance that's already on the new board
let new_board_player_pos = self.board().player_pos();
self.board_mut().get_mut(new_board_player_pos.0, new_board_player_pos.1).take();
// place the player at the arrival portal.
self.board_mut().get_mut(ax, ay).replace(Tile::Player);
self.board_mut().clear_all_queues();
// Rebuild the script host for the new board's objects.
self.scripts = ScriptHost::new(
self.world.boards[&self.current_board_name].clone(),
self.player.clone(),
&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();
}
/// Attempts to move the player one cell in `dir`.
///
/// The move is ignored if the target cell is out of bounds, or it is neither
/// passable nor a pushable solid that can be shoved aside. No-ops silently (the
/// caller does not need to check). If a solid object lies in the path — directly
/// or at the end of a chain of crates the player is shoving — its `bump` hook
/// fires with the direction the bump came from (whether or not the player moves).
pub fn try_move(&mut self, dir: Direction) {
let (dx, dy): (i64, i64) = dir.into();
let player_loc = self.board().player_pos();
let target = (player_loc.0 as i64 + dx, player_loc.1 as i64 + dy);
if !self.board().in_bounds(target) {
return;
}
let (nx, ny) = (target.0 as usize, target.1 as usize);
// We need to be able to call hooks on objects, so we can't hold a mut reference to the
// board going into this match
let obj_data = {
if let Some(Tile::Object(b)) = self.board_mut().get(nx, ny) && let def = b.as_ref() {
Some((def.scripting.id, def.enter_response))
} else {
None
}
};
if let Some((id, enter_response)) = obj_data {
let actions = match enter_response {
EnterResponse::Block => {
// Call the bump hook
self.scripts.run_bump(id, dir.opposite())
}
EnterResponse::Grab => {
// Call the hook to get the actions and then stamp the player on top
let a = self.scripts.run_grab(id);
{
let mut board = self.board_mut();
board.get_mut(player_loc.0, player_loc.1).take();
board.get_mut(nx, ny).replace(Tile::Player);
}
a
}
EnterResponse::Push(pushable) => {
// First, can we push?
if self.board().can_push(nx, ny, dir) {
// The player is pushable, so, this amounts to the same thing and saves a
// couple replace()s
self.board_mut().push(player_loc.0, player_loc.1, dir);
vec![] // There's no push hook, just pushing doesn't call scripts
} else {
// This is actually not pushable in this way, so we're gonna bump instead:
self.scripts.run_bump(id, dir.opposite())
}
}
EnterResponse::Hook => {
vec![] // TODO this hook needs to exist and work. It's documented in tile.rb. Has implications for push as well
// plan: get rid of can_push in board. Make a board::pushes_into_hook or something, find the hook-enter
// object at the end of this chain. Trying to push calls that, if there's no hook object then it pushes, if
// that returns false then it bumps. If there is a hook object, call the hook, run the actions. If it _leaves
// the cell empty,_ then call push. Otherwise bump.
// Maybe have a pushresult enum or something that board::push returns, "hook(id, bumpid)" or "bump(id)" or "moved".
// If the situation is: `@b++h` then moving to the east, the bumpid would be b (the thing you actually touched),
// hook id would be h (the thing with the hook enterresponse). Board can identify object chains but not actually
// call hooks.
}
EnterResponse::Swap => {
let mut board = self.board_mut();
// Swap the two
let tgt = board.get_mut(nx, ny).replace(Tile::Player);
*board.get_mut(player_loc.0, player_loc.1) = tgt;
vec![]
}
EnterResponse::Squish => {
let mut board = self.board_mut();
// Stamp over it, squishing it
board.get_mut(player_loc.0, player_loc.1).take();
board.get_mut(nx, ny).replace(Tile::Player);
vec![]
}
};
self.apply_actions(actions);
} else {
// There's not an object there, we can just move the player
let mut board = self.board_mut();
board.get_mut(player_loc.0, player_loc.1).take();
board.get_mut(nx, ny).replace(Tile::Player);
}
// Check if we actually moved
let new_loc = self.board().player_pos();
if new_loc != player_loc {
// Portals take priority: if we're on a portal it doesn't matter what else we entered:
let portal_info = {
if let Some(Portal { target_board, target_name, ..}) = self.board().portal_at(new_loc.0, new_loc.1) {
Some((target_board.clone(), target_name.clone()))
} else { None }
};
if let Some((target_board, target_name)) = portal_info {
self.enter_board(&target_board, &target_name)
} else {
// We're still on the board, so see if we stepped on any sensors:
let sensor_ids = self.board().sensor_ids_at(new_loc.0, new_loc.1);
for id in sensor_ids {
let actions = self.scripts.run_enter(id, dir.opposite());
self.apply_actions(actions)
}
}
}
self.drain_log();
}
}
/// Moves object `id` one cell in `dir` on `board`, reporting the `bump`/`enter`
/// reactions for the caller to resolve after the board borrow drops.
///
/// 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 for the solid object the move presses into — directly, or at the
/// end of a chain of crates being shoved (see [`Board::bump_target`]) — whether it
/// gets pushed aside or blocks the move. Walls and crates carry no script, so only
/// solid objects yield a bump. An `enter` is recorded for every non-solid object a
/// solid lands on: the mover's destination cell (only when the mover is itself solid)
/// and each cell a pushed crate moved into (crates are always solid entrants).
fn step_object(board: &mut Board, id: ObjectId, dir: Direction) {
// TODO when an object pushes the player, it should still trigger actions on
// what the player is pushed into. But, for right now, just call board::push
let (loc, solid) = if let Some(obj) = board.get_hookable(id) {
(obj.location(), obj.solid())
} else { return };
if solid {
// This is a real object on the board, try and push it
board.move_object(loc.0, loc.1, dir);
} else {
// This is a sensor, we can just teleport it
board.move_sensor(id, dir);
}
}
#[cfg(test)]
mod tests {
use super::GameState;
use crate::Direction;
use crate::board::tests::{
builtin_at, crate_at, gem_at, is_builtin, open_board, sensor_at, wall_at,
};
use std::collections::HashMap;
use std::time::Duration;
#[test]
fn walking_onto_a_gem_grabs_it() {
// A gem builtin object at (1,0), running scripts/gem.rhai. Its
// EnterResponse::Grab means the player's step fires grab() rather than being
// blocked. The player starts at (0,0).
let mut board = open_board(3, 1, (0, 0));
gem_at(&mut board, 1, 0);
let mut game = GameState::new(board);
game.run_init();
game.try_move(Direction::East);
// The gem was grabbed: gem count up, gem object gone, player on its cell.
assert_eq!(game.player.borrow().gems, 1);
assert!(game.board().all_ids().is_empty());
assert_eq!(game.board().player_pos(), (1, 0));
}
/// Builds a `GameState` with a script-only sensor at (0,0) running `src`, a crate
/// at (2,0), and the player parked against the right-hand edge.
///
/// The script host is a [`Sensor`](crate::tile::Sensor) rather than an object:
/// every grid object is solid now, so a scripted thing that must not interfere
/// with movement has to live off-grid.
fn game_with_object_script(board_w: usize, src: &str) -> GameState {
let mut board = open_board(board_w, 1, (board_w - 1, 0));
sensor_at(&mut board, 0, 0, "s");
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_shift_rotates_crates() {
// Rotate the crate at (2,0) with the empty cell at (3,0) in a two-cell cycle:
// crate moves to (3,0), empty moves back to (2,0). No queue guard is needed —
// the host only calls `tick` when the object's queue has drained.
let mut game = game_with_object_script(5, "fn tick(me, dt) { shift([[2, 0], [3, 0]]); }");
game.tick(Duration::from_millis(16));
let b = game.board();
assert!(b.get(2, 0).is_none());
assert!(is_builtin(&b, 3, 0, "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 script
// sensor sits at (0,0) — sensors never occupy a cell, so it does not make its
// own cell impassable; the player is at (3,0). `passable` should report only
// the genuinely empty in-bounds cell.
let mut board = open_board(4, 1, (3, 0));
sensor_at(&mut board, 0, 0, "s");
crate_at(&mut board, 2, 0);
let src = "fn init(me) { \
log(if Board.passable(1, 0) { \"empty:yes\" } else { \"empty:no\" }); \
log(if Board.passable(2, 0) { \"crate:yes\" } else { \"crate:no\" }); \
log(if Board.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"]);
}
#[test]
fn log_is_immediate_and_not_paced_by_the_queue() {
// `delay(5.0)` parks the sensor's action queue for 5 seconds, then `log()`
// fires. Because logging bypasses the queue entirely, the line must appear
// right after run_init (dt = 0) — under the old queued-Action behavior it
// would have been stuck behind the delay and absent here.
let mut board = open_board(4, 1, (3, 0));
sensor_at(&mut board, 0, 0, "s");
let src = "fn init(me) { delay(5.0); log(\"immediate\"); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert!(
game.log
.iter()
.any(|l| l.spans.iter().any(|s| s.text == "immediate")),
"log() should hit the game log immediately, even behind a delay"
);
}
/// Builds a board with a clockwise `spinner_cw` builtin at (1,1), plus crates and
/// walls at the given ring cells.
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
spinner_board_dir(crates, walls, false)
}
/// Like [`spinner_board`] but `ccw` stamps the `spinner_ccw` alias instead, so the
/// shared `scripts/spinner.rhai` reads its `BUILTIN_spinner_ccw` tag and spins the
/// other way (clockwise is the default when the tag is absent).
///
/// The board is 4×3, not 3×3: the spinner is a solid object now, so the player
/// needs a cell of its own at (3,1) instead of being parked on the spinner, and
/// the 8-cell ring around (1,1) must stay fully in bounds or `apply_shift`
/// rejects the whole rotation. Ring coordinates are unchanged from the 3×3
/// layout — N(1,0) NE(2,0) E(2,1) SE(2,2) S(1,2) SW(0,2) W(0,1) NW(0,0).
fn spinner_board_dir(
crates: &[(usize, usize)],
walls: &[(usize, usize)],
ccw: bool,
) -> GameState {
let mut board = open_board(4, 3, (3, 1));
// Stamped first, so the spinner holds the lowest id and ticks before anything else.
builtin_at(&mut board, 1, 1, if ccw { "spinner_ccw" } else { "spinner_cw" });
for &(x, y) in crates {
crate_at(&mut board, x, y);
}
for &(x, y) in walls {
wall_at(&mut board, x, y);
}
// No world script pool: a builtin carries its own ScriptKey and source.
let mut game = GameState::new(board);
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). The wall is immobile, and
// `apply_shift` cascades that backward over the contiguous run of solids
// behind it, so N must not rotate onto NE — an earlier one-cell-lookahead
// version overwrote and destroyed NE's crate. Everything stays put.
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert!(is_builtin(&b, 1, 0, "crate")); // N kept
assert!(is_builtin(&b, 2, 0, "crate")); // NE kept (not destroyed)
assert!(is_builtin(&b, 2, 1, "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!(is_builtin(&b, 2, 0, "crate")); // NE: filled by N
assert!(is_builtin(&b, 1, 0, "crate")); // N: filled by NW
assert!(is_builtin(&b, 0, 0, "crate")); // NW: filled by W
assert!(b.get(0, 1).is_none()); // 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!(is_builtin(&b, 0, 0, "crate")); // NW: crate rotated counter-clockwise
assert!(b.get(2, 0).is_none()); // NE: untouched
assert!(b.get(1, 0).is_none()); // 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(&[], &[]);
// With no crates or walls the spinner is the board's only object; the player
// is a `Tile::Player`, which carries no id.
let id = game.board().all_ids()[0];
let glyph_of = |game: &GameState| {
game.board()
.get_hookable(id)
.expect("the spinner stays on the board")
.glyph()
};
let base = glyph_of(&game);
let mut seq = Vec::new();
for _ in 0..5 {
game.tick(Duration::from_secs_f64(0.5));
let g = glyph_of(&game);
seq.push(g.tile);
assert_eq!(g.fg, base.fg, "fg unchanged");
assert_eq!(g.bg, base.bg, "bg unchanged");
}
assert_eq!(seq, vec!['/', '─', '\\', '│', '/']);
}
#[test]
fn set_key_gives_and_takes_keys() {
let mut board = open_board(2, 1, (1, 0));
sensor_at(&mut board, 0, 0, "s");
let scripts = HashMap::from([(
"s".to_string(),
r#"fn init(me) { set_key("blue", true); set_key("red", true); }"#.to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
let keys = game.player.borrow().keys;
assert!(keys.blue);
assert!(keys.red);
assert!(!keys.cyan); // cyan was not set by the script
// A second script can take a key.
let mut board2 = open_board(2, 1, (1, 0));
sensor_at(&mut board2, 0, 0, "t");
let scripts2 = HashMap::from([(
"t".to_string(),
r#"fn init(me) { set_key("blue", true); set_key("blue", false); }"#.to_string(),
)]);
let mut game2 = GameState::with_scripts(board2, scripts2);
game2.run_init();
assert!(!game2.player.borrow().keys.blue);
}
#[test]
fn set_key_unknown_color_logs_error() {
let mut board = open_board(2, 1, (1, 0));
sensor_at(&mut board, 0, 0, "s");
let scripts = HashMap::from([(
"s".to_string(),
r#"fn init(me) { set_key("chartreuse", true); }"#.to_string(),
)]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert!(game.log.iter().any(|l| l.spans.iter().any(|s| s.text.contains("set_key"))));
}
}