Files
kiln/kiln-core/src/game.rs
T
2026-08-10 22:57:54 -05:00

796 lines
35 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_shift, apply_teleport, Action, BoardAction, SendArg};
use crate::board::Board;
use crate::log::LogLine;
use crate::script::ScriptHost;
use crate::utils::{Direction, ObjectId, Point, Pushable};
use crate::world::World;
use std::cell::{Ref, RefMut};
use std::collections::{BTreeSet, HashSet, VecDeque};
use std::fmt::format;
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 } => {
self.resolve_move((x as usize, y as usize), 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();
}
fn resolve_move(&mut self, from: (usize, usize), dir: Direction) -> bool {
// Get the target coords, if they're out of bounds then the move fails.
let target: Point = dir.from_point(from.0 as i64, from.1 as i64).into();
if !self.board().in_bounds((target.x, target.y)) { return false; }
if self.board().is_empty(target) {
// If it's empty, we can move in there; do so and return true:
self.board_mut().move_cell(from.into(), target);
} else if self.board().is_player(target) {
// Target cell contains player, who's pushable, so we'll recurse and see what happens:
if self.resolve_move((target.x as usize, target.y as usize), dir) {
self.board_mut().move_cell(from.into(), target);
}
} else {
// Otherwise, what enter response does the thing there have? 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 id = {
if let Some(Tile::Object(b)) = self.board_mut().get(target.x as usize, target.y as usize) && let def = b.as_ref() {
def.scripting.id
} else {
unreachable!("moving into the player was handled above")
}
};
// Whether the player is what initiated the move directly. Matters for grab / swap
// let from_player = matches!(self.board().get(from.0, from.1), Some(Tile::Player));
// Call the target's bump hook and resolve whatever it did
let actions = self.scripts.run_bump(id, dir.opposite());
self.apply_actions(actions);
// First do things to try and call any hooks relevant:
// let actions = match resp {
// // We block all moves, return false
// EnterResponse::Block => { self.scripts.run_bump(id, dir.opposite()) }
//
// // The player can grab things directly, so let's do that
// EnterResponse::Grab if from_player => {
// let a = self.scripts.run_grab(id);
// self.board_mut().get_mut(target.0, target.1).take();
// a
// }
//
// // Grabbables not from the player act as pushable, we need to recurse
// EnterResponse::Grab => { self.resolve_move(target, dir); vec![] }
//
// // Pushable if we're allowed to push that way recurses
// EnterResponse::Push(p) if p.allows(dir) => { self.resolve_move(target, dir); vec![] }
//
// // Pushable in a disallowed direction blocks
// EnterResponse::Push(p) => { self.scripts.run_bump(id, dir.opposite()) }
//
// // Swaps let the player swap places, but we should return false afterward because we
// // haven't left the source cell empty. In practice this won't matter because right
// // now we never recurse _into_ a player-source-cell, all moves are initiated by the
// // player, but still.
// EnterResponse::Swap if from_player => {
// let mut board = self.board_mut();
// let mover = board.get_mut(from.0, from.1).take();
// let swapper = board.get_mut(target.0, target.1).take();
// board.get_mut(target.0, target.1).replace(mover.unwrap());
// board.get_mut(from.0, from.1).replace(swapper.unwrap());
// vec![]
// }
//
// // Swaps in a chain are just normal pushable, recurse:
// EnterResponse::Swap => { self.resolve_move(target, dir); vec![] }
//
// // Squish just lets the mover overwrite, and always leaves the source cell empty:
// EnterResponse::Squish => { self.board_mut().get_mut(from.0, from.1).take(); vec![] }
//
// // Finally, hook, we need to call a hook and let it do its thing:
// EnterResponse::Hook => { self.scripts.run_bump(id, dir.opposite()) }
// };
// If there were actions performed by the hooks, do them.
// self.apply_actions(actions);
// Now, check again if the target cell is empty. If it is, put the mover there. Also
// check that the source cell still contains something! It may have been teleported away.
if !self.board().is_empty(from.into()) && self.board().is_empty(target) {
self.board_mut().move_cell(from.into(), target);
}
}
// Either way, return whether the source cell is now empty, so calls up the chain
// can move into it (push chains)
self.board().is_empty(from.into())
}
/// 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;
}
self.resolve_move(player_loc, dir);
// 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"))));
}
}