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, /// 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, } /// 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, /// 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, /// 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, /// 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, /// 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) -> Self { // Wrap the bare board in Rc> 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> { 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) { // 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 = 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")))); } }