use crate::action::Action; use crate::board::Board; use crate::log::LogLine; use crate::script::ScriptHost; use crate::utils::{Direction, ObjectId, PlayerPos, ScriptArg}; use crate::world::World; use std::cell::{Ref, RefMut}; 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; /// 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 player's state pub player: Player, } 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 host = ScriptHost::new( world .boards .get(&name) .expect("world::load guarantees start board exists"), &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: Player::default() }; state.drain_errors(); 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(), 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 } /// 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) { self.scripts.run_init(self.player); self.resolve(); } /// 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(); self.scripts.run_tick(self.player, secs); // Expire speech bubbles before resolving 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 }); self.resolve(); } /// Drains errors collected by the script host into the game log. fn drain_errors(&mut self) { // TODO: errors are only logged for now. This is the place to halt execution / // set an error state when a script faults. let errors = self.scripts.take_errors(); self.log.extend(errors); } /// Resolves the board queue: applies each promoted action to the board, then fires /// the `bump` and `send` hooks the moves triggered. /// /// Done in two phases so no `board_mut` borrow is held while scripts run /// (they read the board through its getters): phase A mutates the board and records /// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B fires the /// hooks after the borrow drops. fn resolve(&mut self) { let actions = self.scripts.take_board_queue(); // Logs are collected here rather than pushed inline, since the board borrow // below also borrows `self`. let mut logs: Vec = Vec::new(); let mut bumps: Vec<(ObjectId, i64)> = Vec::new(); // Net change to player stats from AddGems / AlterHealth actions; applied // to `self` after the board borrow drops. let mut gem_delta: i64 = 0; let mut health_delta: i64 = 0; let mut key_changes: Vec<(String, bool)> = Vec::new(); let mut sends: Vec<(ObjectId, String, Option)> = Vec::new(); let mut new_bubbles: Vec = Vec::new(); // Collected outside the board borrow so we can assign to self.active_scroll. let mut new_scroll: Option = None; { let mut board = self.board_mut(); for ba in actions { match ba.action { Action::Move(dir) => { if let Some(bumped) = step_object(&mut board, ba.source, dir) { bumps.push((bumped, ba.source as i64)); } } Action::SetTile(tile) => { if let Some(obj) = board.objects.get_mut(&ba.source) { obj.glyph.tile = tile; } } Action::Log(line) => logs.push(line), Action::SetTag { target, tag, present, } => { if let Some(obj) = board.objects.get_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) => new_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) = board.objects.get_mut(&ba.source) { if let Some(c) = fg { obj.glyph.fg = c; } if let Some(c) = bg { obj.glyph.bg = c; } } } // Collected and fired after the board borrow drops, like bumps. Action::Send { target, fn_name, arg, } => { sends.push((target, fn_name, arg)); } // Later scrolls overwrite earlier ones from the same tick. Action::Scroll(lines) => { new_scroll = Some(Scroll { source: ba.source, lines, choice: None, }); } Action::Teleport { x, y } => { if !board.in_bounds((x, y)) { logs.push(LogLine::error(format!("teleport({x},{y}): out of bounds"))); } else { let (ux, uy) = (x as usize, y as usize); let source_solid = board .objects .get(&ba.source) .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; } } } // 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)) { logs.push(LogLine::error(format!("push({x},{y}): out of bounds"))); } else { board.push(x as usize, y as usize, dir); } } // apply_shift moves the named cells. Action::Shift(cells) => { logs.extend(board.apply_shift(&cells)); } // Accumulated and applied to `self.player_gems` after the borrow drops. Action::AddGems(n) => gem_delta += n, // Accumulated and applied to `self.player_health` after the borrow drops. Action::AlterHealth(dh) => health_delta += dh, // Collected and applied to `self.player_keys` after the borrow drops. Action::SetKey(color, present) => key_changes.push((color, present)), // A grab thing despawns itself from its grab() hook. Action::Die => { board.remove_object(ba.source); } } } } self.log.extend(logs); for bubble in new_bubbles { // One bubble per object: replace the existing one if present. self.speech_bubbles .retain(|b| b.object_id != bubble.object_id); self.speech_bubbles.push(bubble); } if let Some(scroll) = new_scroll { self.active_scroll = Some(scroll); } // Apply the net gem change (clamped at 0, since the count is unsigned). if gem_delta != 0 { self.player.alter_gems(gem_delta); } // Apply the net health change (clamped to [0, max_health]). if health_delta != 0 { self.player.alter_health(health_delta); } for (color, present) in key_changes { if !self.player.keys.set_by_name(&color, present) { self.log.push(LogLine::error(format!("set_key: unknown color {color:?}"))); } } for (bumped, bumper) in bumps { self.scripts.run_bump(self.player, bumped, bumper); } for (target, fn_name, arg) in sends { self.scripts.run_send(self.player, target, &fn_name, arg); } self.drain_errors(); } /// 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 { self.scripts.run_send(self.player, scroll.source, &choice, None); self.drain_errors(); } } /// 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() .portals .iter() .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(); self.board_mut().player = PlayerPos { x: ax as i32, y: ay as i32, }; // 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(); } /// 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 the target cell holds a solid object, that /// object's `bump(-1)` hook fires (whether or not the player ends up moving). pub fn try_move(&mut self, dir: Direction) { let bumped; let grabbed; let portal_target; { 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); // Walking onto a grab thing (e.g. a gem) is never blocked: the player // moves onto it and its grab() hook fires (the thing despawns itself). grabbed = board.grab_object_at(nx, ny); // A solid object in the way is bumped by the player (id -1) — but a // grab thing fires grab() instead of bump(), so don't also bump it. bumped = board .solid_at(nx, ny) .filter(|_| grabbed.is_none()) .and_then(|s| s.object_id()); if grabbed.is_some() || board.is_passable(nx, ny) || board.can_push(nx, ny, dir) { // Don't push a grab thing aside — walk onto it. Otherwise shove any // pushable chain out of the way (no-op when there's nothing to push). if grabbed.is_none() { board.push(nx, ny, dir); } board.player.x = nx as i32; board.player.y = ny as i32; // Check for a portal at the new position; clone strings to release the borrow. portal_target = board .portals .iter() .find(|p| p.x == nx && p.y == ny) .map(|p| (p.target_map.clone(), p.target_entry.clone())); } else { portal_target = None; } } // 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; } // Fire the grab hook and resolve it immediately so the grabbed thing's // die()/add_gems() apply now — no player+object overlap survives this call. if let Some(id) = grabbed { self.scripts.run_grab(self.player, id); self.resolve(); } if let Some(idx) = bumped { self.scripts.run_bump(self.player, idx, -1); self.drain_errors(); } } } /// Moves object `id` one cell in `dir` on `board`, returning the object it bumped /// (if any) 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 whenever a solid object occupies the target — whether it gets /// pushed aside or blocks the move — since something tried to move into it. Walls and /// crates carry no script, so only solid objects yield a bump. fn step_object(board: &mut Board, id: ObjectId, dir: Direction) -> Option { let (dx, dy): (i32, i32) = dir.into(); let (ox, oy) = board.objects.get(&id).map(|o| (o.x, o.y))?; let target = (ox as i32 + dx, oy as i32 + dy); if !board.in_bounds(target) { return None; } let (nx, ny) = (target.0 as usize, target.1 as usize); // Capture the bumped object before any push relocates it (its id is stable). let bumped = board.solid_at(nx, ny).and_then(|s| s.object_id()); if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) { board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise let obj = board.objects.get_mut(&id).expect("id checked above"); obj.x = nx; obj.y = ny; } bumped } #[cfg(test)] mod tests { use super::GameState; use crate::Direction; use crate::archetype::{Archetype, Builtin}; use crate::board::tests::{crate_at, open_board, stamp, wall_at}; use crate::object_def::ObjectDef; use std::collections::HashMap; use std::time::Duration; #[test] fn walking_onto_a_gem_grabs_it() { // A gem terrain cell at (1,0); expanding turns it into the builtin gem // object running scripts/gem.rhai. The player starts at (0,0). let mut board = open_board(3, 1, (0, 0), vec![]); stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem")); board.expand_builtin_archetypes(); 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.gems, 1); assert!(game.board().objects.is_empty()); assert_eq!((game.board().player.x, game.board().player.y), (1, 0)); } #[test] fn pushing_a_gem_into_the_player_does_not_grab() { // A non-solid script object at (0,0) pushes the gem at (1,0) east toward the // player at (2,0). Grab now fires only on player movement, so the gem is an // ordinary solid here: the chain can't move (the player is backed against // the board edge), so nothing happens and the gem is not collected. let mut sobj = ObjectDef::new(0, 0); sobj.solid = false; sobj.script_name = Some("s".to_string()); let mut board = open_board(3, 1, (2, 0), vec![sobj]); stamp(&mut board, 1, 0, Archetype::Builtin(Builtin::Gem, "gem")); board.expand_builtin_archetypes(); let scripts = HashMap::from([( "s".to_string(), "fn tick(dt) { if Queue.length() == 0 { push(1, 0, East); } }".to_string(), )]); let mut game = GameState::with_scripts(board, scripts); game.run_init(); game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16)); // No grab: the gem is untouched and the player never moved. assert_eq!(game.player.gems, 0); assert!(game.board().objects.values().any(|o| o.grab)); assert_eq!((game.board().player.x, game.board().player.y), (2, 0)); } /// 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_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). let mut game = game_with_object_script( 5, "fn tick(dt) { if Queue.length() == 0 { shift([[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 = 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]); } #[test] fn set_key_gives_and_takes_keys() { let mut sobj = ObjectDef::new(0, 0); sobj.solid = false; sobj.script_name = Some("s".to_string()); let board = open_board(2, 1, (1, 0), vec![sobj]); let scripts = HashMap::from([( "s".to_string(), "fn init() { set_key(\"blue\", true); set_key(\"red\", true); }".to_string(), )]); let mut game = GameState::with_scripts(board, scripts); game.run_init(); assert!(game.player.keys.blue); assert!(game.player.keys.red); assert!(!game.player.keys.cyan); // cyan was not set by the script // A second script can take a key. let mut sobj2 = ObjectDef::new(0, 0); sobj2.solid = false; sobj2.script_name = Some("t".to_string()); let board2 = open_board(2, 1, (1, 0), vec![sobj2]); let scripts2 = HashMap::from([( "t".to_string(), "fn init() { set_key(\"blue\", true); set_key(\"blue\", false); }".to_string(), )]); let mut game2 = GameState::with_scripts(board2, scripts2); game2.run_init(); assert!(!game2.player.keys.blue); } #[test] fn set_key_unknown_color_logs_error() { let mut sobj = ObjectDef::new(0, 0); sobj.solid = false; sobj.script_name = Some("s".to_string()); let board = open_board(2, 1, (1, 0), vec![sobj]); let scripts = HashMap::from([( "s".to_string(), r#"fn init() { 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")))); } }