//! Rhai scripting runtime for board objects. //! //! [`ScriptHost`] owns the Rhai [`Engine`], the compiled scripts referenced by a //! board's objects, and a persistent per-object [`Scope`]. It drives the optional //! lifecycle hooks on each scripted object: //! //! - `init()` — run once after the whole map is loaded (see [`ScriptHost::run_init`]). //! - `tick(dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]). //! - `bump(id)` — run when another mover steps into this object's cell, with the //! bumper's object index (`-1` for the player); see [`ScriptHost::run_bump`]. //! //! ## Actions, queues, and rate limiting //! //! Scripts don't mutate the world directly. Each write host function (`move`, //! `set_tile`, `log`, `say`) appends an [`Action`] to the issuing object's **output //! queue** (a per-object FIFO). //! //! Timing is encoded **inline** in the queue as [`Action::Delay`] entries rather than //! as an external per-object timer. `move(dir)` appends both a `Move` and a //! `Delay(MOVE_COST)` so the object pauses before its next action. Adjacent delays are //! merged at push time so the queue never contains two consecutive `Delay`s. //! //! Each tick, [`ScriptHost::drain`] advances the queue for each object: //! it promotes non-delay actions onto the shared **board queue** until it hits a live //! `Delay`, which it decrements (and removes when expired). `dt` is consumed on the //! first `Delay` encountered so multiple delays in one tick are not double-decremented. //! //! The `now()` host function pops the most recently enqueued action to the front of //! the queue, letting scripts prioritize a zero-cost action (e.g. `say`) over a //! pending delay. //! //! [`crate::game::GameState`] drains the board queue ([`take_board_queue`]) and applies //! each action *after* the script batch, preserving the borrow discipline (scripts only //! ever read the board during execution). Which object issued an action is read from the //! per-call **tag** (set to the object index in [`ScriptHost::run`]). //! //! [`take_board_queue`]: ScriptHost::take_board_queue //! [`GameState`]: crate::game::GameState use crate::board::Board; use crate::log::LogLine; use rhai::{CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope, AST}; use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; use crate::utils::{Action, Direction, ObjectId, MOVE_COST}; /// An action promoted from an object's output queue onto the board queue, tagged /// with the object that issued it. pub struct BoardAction { /// The stable [`ObjectId`] of the object that issued the action. pub source: ObjectId, /// The action to apply. pub action: Action, } /// A read-only handle to the world, exposed to scripts as `Board`. Holds a shared /// reference to the [`Board`]; only getters are registered, so it is /// read-only by construction. type BoardRef = Rc>; /// A single object's output queue, shared between the host (which pumps it between /// script calls) and the script (which reads/mutates it through the `Queue` object). type ObjQueue = Rc>>; /// The board queue: actions promoted from object queues, awaiting resolution. Shared /// so the `blocked` host function can scan pending moves during script execution. type BoardQueue = Rc>>; /// Maps an [`ObjectId`] to its output queue, so the global write host functions can /// route an emitted action to the right object via the per-call tag. type QueueMap = Rc>>; /// Channel for engine/compile/runtime errors, surfaced straight to the game log /// (errors never go through the action queues). type ErrorSink = Rc>>; /// A compiled script plus which lifecycle hooks it defines. struct CompiledScript { /// The compiled Rhai AST (function definitions plus any top-level code). ast: AST, /// Whether the script defines `fn init()` (zero parameters). has_init: bool, /// Whether the script defines `fn tick(dt)` (one parameter). has_tick: bool, /// Whether the script defines `fn bump(id)` (one parameter). has_bump: bool, } /// The runtime state of one scripted object: which script it runs, its persistent /// Rhai scope, and its pending action queue. struct ObjectRuntime { /// The stable [`ObjectId`] of this object; passed to scripts as the per-call /// tag so host functions know which object issued an action. object_id: ObjectId, /// Key into [`ScriptHost::scripts`] naming this object's compiled script. script_name: String, /// Per-object scope (holds the `Board`/`Queue` views + direction constants, and /// can hold object-local state in the future). Persists across calls. scope: Scope<'static>, /// This object's output queue (shared with its scope's `Queue` object). /// May contain [`Action::Delay`] entries that pace the release of other actions. output_queue: ObjQueue, } /// Owns the Rhai engine and per-object script state for a board. pub struct ScriptHost { /// The Rhai engine, with the read getters and write host functions registered. engine: Engine, /// Compiled scripts, keyed by script name; compiled once per referenced name. scripts: HashMap, /// One entry per object that has a successfully compiled script. objects: Vec, /// Actions promoted from object queues, drained by [`Self::take_board_queue`]. board_queue: BoardQueue, /// Errors collected during compile/run, drained by [`Self::take_errors`]. errors: ErrorSink, } impl ScriptHost { /// Builds a host for the board behind `board_cell`: registers the read/write API, /// compiles every script referenced by an object (once each), and creates a fresh /// scope and output queue per scripted object. Compile errors and references to /// unknown scripts are queued onto the error sink (retrievable via /// [`Self::take_errors`]); no script is run here. pub fn new(board_cell: &BoardRef) -> Self { let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new())); let queues: QueueMap = Rc::new(RefCell::new(HashMap::new())); let errors: ErrorSink = Rc::new(RefCell::new(Vec::new())); let mut engine = Engine::new(); register_read_api(&mut engine, board_cell, &board_queue); register_write_api(&mut engine, &queues); register_queue_api(&mut engine); let board = board_cell.borrow(); // Compile each referenced script once; attribute failures to the first // object that references the script. let mut scripts: HashMap = HashMap::new(); let mut failed: HashSet = HashSet::new(); for obj in board.objects.values() { let Some(name) = obj.script_name.as_ref() else { continue; }; if scripts.contains_key(name) || failed.contains(name) { continue; } match board.scripts.get(name) { Some(src) => match engine.compile(src) { Ok(ast) => { // Detect the optional hooks by name and arity. let defines = |n: &str, params: usize| { ast.iter_functions() .any(|f| f.name == n && f.params.len() == params) }; let compiled = CompiledScript { has_init: defines("init", 0), has_tick: defines("tick", 1), has_bump: defines("bump", 1), ast, }; scripts.insert(name.clone(), compiled); } Err(err) => { failed.insert(name.clone()); errors.borrow_mut().push(LogLine::raw(format!( "script '{name}' failed to compile: {err}" ))); } }, None => { failed.insert(name.clone()); errors.borrow_mut().push(LogLine::raw(format!( "object references unknown script '{name}'" ))); } } } // One runtime (independent scope + output queue) per object whose script // compiled. The queue is registered in `queues` so host functions can find // it by the object's index, and shared into the scope as the `Queue` object. let mut objects = Vec::new(); for (&id, obj) in board.objects.iter() { let Some(name) = obj.script_name.as_ref() else { continue; }; if !scripts.contains_key(name) { continue; } let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new())); queues.borrow_mut().insert(id, queue.clone()); objects.push(ObjectRuntime { object_id: id, script_name: name.clone(), scope: new_object_scope(board_cell, &queue, id), output_queue: queue, }); } drop(board); Self { engine, scripts, objects, board_queue, errors, } } /// Calls `init()` on every scripted object that defines it and drains each queue /// with `dt = 0` (no time advance — just flush any zero-cost front actions). pub fn run_init(&mut self) { self.run("init", |c| c.has_init, (), 0.0); } /// Calls `tick(dt)` on every scripted object that defines it, then drains each /// queue, advancing inline [`Action::Delay`]s by `dt`. pub fn run_tick(&mut self, dt: f64) { self.run("tick", |c| c.has_tick, (dt,), dt); } /// Calls `bump(id)` on the object with [`ObjectId`] `object_id`, if it defines the hook. /// /// `bumper` is the id of the object that moved into it, or `-1` for the player. /// After the hook, drains the object's queue with `dt = 0` so any zero-cost actions /// the hook enqueued (e.g. `say(); now()`) take effect in the same frame. pub fn run_bump(&mut self, object_id: ObjectId, bumper: i64) { // Find the index first so we can drain after the split-borrow block. let Some(i) = self.objects.iter().position(|o| o.object_id == object_id) else { return; }; { let Self { engine, scripts, objects, errors, .. } = self; let obj = &mut objects[i]; let Some(compiled) = scripts.get(&obj.script_name) else { return; }; if !compiled.has_bump { return; } let options = CallFnOptions::default().with_tag(object_id as i64); if let Err(err) = engine.call_fn_with_options::<()>( options, &mut obj.scope, &compiled.ast, "bump", (bumper,), ) { errors.borrow_mut().push(LogLine::raw(format!( "script '{}' bump error: {err}", obj.script_name ))); } } // dt=0: flush zero-cost front actions without advancing any delays. self.drain(i, 0.0); } /// Removes and returns the actions promoted onto the board queue. pub fn take_board_queue(&mut self) -> Vec { std::mem::take(&mut self.board_queue.borrow_mut()) } /// Removes and returns the errors collected since the last drain. pub fn take_errors(&mut self) -> Vec { std::mem::take(&mut self.errors.borrow_mut()) } /// Drains object `i`'s output queue onto the board queue, advancing inline /// [`Action::Delay`]s by `dt`. /// /// Non-delay actions are promoted immediately. When a `Delay` is encountered it is /// decremented by the remaining `dt` (which is then consumed so subsequent delays /// in the same call are not also decremented). An expired delay is removed and /// draining continues; a live delay stops the loop for this call. fn drain(&mut self, i: usize, mut dt: f64) { let oid = self.objects[i].object_id; loop { let queue = self.objects[i].output_queue.clone(); // Peek at the front to decide what to do without holding the borrow. let is_delay = matches!(queue.borrow().front(), Some(Action::Delay(_))); if is_delay { // Decrement the delay; pop it only if it expires. let expired = { let mut q = queue.borrow_mut(); let Some(Action::Delay(r)) = q.front_mut() else { break }; *r -= dt; dt = 0.0; // consume so later delays in this call aren't also decremented *r <= 0.0 }; if expired { queue.borrow_mut().pop_front(); // Continue — drain actions after the expired delay. } else { break; // Live delay: stop draining. } } else { let Some(action) = queue.borrow_mut().pop_front() else { break }; self.board_queue.borrow_mut().push(BoardAction { source: oid, action }); } } } /// Shared driver for `init`/`tick`: for each object, call the hook if its script /// defines it (tagging the call with the object's id so host functions know the /// source), then drain that object's queue so actions reach the board queue before /// the next object's `blocked` check runs. fn run( &mut self, hook: &str, defined: fn(&CompiledScript) -> bool, args: A, drain_dt: f64, ) { for i in 0..self.objects.len() { // Scope the split borrow so it ends before the drain reborrow below. { let Self { engine, scripts, objects, errors, .. } = self; let obj = &mut objects[i]; if let Some(compiled) = scripts.get(&obj.script_name) && defined(compiled) { let options = CallFnOptions::default().with_tag(obj.object_id as i64); if let Err(err) = engine.call_fn_with_options::<()>( options, &mut obj.scope, &compiled.ast, hook, args, ) { errors.borrow_mut().push(LogLine::raw(format!( "script '{}' {hook} error: {err}", obj.script_name ))); } } } self.drain(i, drain_dt); } } } /// Registers the read-only `Board` API (getters plus the `blocked` query). /// /// The getters operate on the `Board` value in scope; `blocked` instead captures a /// clone of the world and the board queue, so it can resolve the caller's position and /// scan pending moves. fn register_read_api(engine: &mut Engine, board: &BoardRef, board_queue: &BoardQueue) { engine.register_type_with_name::("Board"); engine.register_get("player_x", |b: &mut BoardRef| b.borrow().player.x as i64); engine.register_get("player_y", |b: &mut BoardRef| b.borrow().player.y as i64); engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64); engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64); let b = board.clone(); let bq = board_queue.clone(); engine.register_fn("blocked", move |ctx: NativeCallContext, dir: Direction| { is_blocked(&b, &bq, source_of(&ctx), dir) }); // has_tag(s) -> bool: does the calling object have this tag? let b = board.clone(); engine.register_fn("has_tag", move |ctx: NativeCallContext, tag: ImmutableString| { let src = source_of(&ctx); b.borrow().objects.get(&src).is_some_and(|o| o.tags.contains(tag.as_str())) }); // get_tags() -> Array of strings: the calling object's current tag set. let b = board.clone(); engine.register_fn("get_tags", move |ctx: NativeCallContext| -> rhai::Array { let src = source_of(&ctx); b.borrow() .objects .get(&src) .map(|o| o.tags.iter().map(|t| rhai::Dynamic::from(t.clone())).collect()) .unwrap_or_default() }); // objects_with_tag(s) -> Array of i64: ids of all objects carrying the tag. let b = board.clone(); engine.register_fn( "objects_with_tag", move |_ctx: NativeCallContext, tag: ImmutableString| -> rhai::Array { b.borrow() .objects .iter() .filter(|(_, o)| o.tags.contains(tag.as_str())) .map(|(&id, _)| rhai::Dynamic::from(id as i64)) .collect() }, ); // my_name() -> String: the calling object's name, or "" if unnamed. let b = board.clone(); engine.register_fn("my_name", move |ctx: NativeCallContext| -> String { let src = source_of(&ctx); b.borrow() .objects .get(&src) .and_then(|o| o.name.as_deref()) .unwrap_or("") .to_string() }); // object_id_for_name(s) -> i64: id of the object with the given name, or 0 if none. let b = board.clone(); engine.register_fn( "object_id_for_name", move |_ctx: NativeCallContext, name: ImmutableString| -> i64 { b.borrow() .objects .iter() .find(|(_, o)| o.name.as_deref() == Some(name.as_str())) .map(|(&id, _)| id as i64) .unwrap_or(0) }, ); } /// Whether the object at `source` would bump something by moving in `dir`. /// /// Returns `true` if the target cell is off the board, already holds a solid, or is /// the destination of any pending move on the board queue. The caller's own move is /// never on the queue yet (its pump runs after its script), so it can't block itself. /// /// Pending moves are matched by their *immediate* target cell only; a queued move that /// would push a chain of solids into the cell is not accounted for (an approximation). fn is_blocked( board: &BoardRef, board_queue: &BoardQueue, source: ObjectId, dir: Direction, ) -> bool { let board = board.borrow(); let Some(obj) = board.objects.get(&source) else { return true; }; let (dx, dy): (i32, i32) = dir.into(); let target = (obj.x as i32 + dx, obj.y as i32 + dy); if !board.in_bounds(target) { return true; } let (tx, ty) = (target.0 as usize, target.1 as usize); if board.solid_at(tx, ty).is_some() { return true; } // Any pending move whose immediate target is this cell would put a solid here. board_queue.borrow().iter().any(|ba| { if let Action::Move(d) = &ba.action && let Some(mover) = board.objects.get(&ba.source) { let (mdx, mdy): (i32, i32) = (*d).into(); return (mover.x as i32 + mdx, mover.y as i32 + mdy) == target; } false }) } /// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log`/ /// `say`/`now` host functions. /// /// `move(dir)` pushes both a `Move` and a `Delay(MOVE_COST)` so the object's queue /// paces itself automatically. `now()` promotes the most recently enqueued action to /// the front of the queue, letting a zero-cost action bypass a pending delay. fn register_write_api(engine: &mut Engine, queues: &QueueMap) { engine.register_type_with_name::("Direction"); // move(dir): enqueue a Move followed by a rate-limiting Delay. let q = queues.clone(); engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { let src = source_of(&ctx); if let Some(queue) = q.borrow().get(&src) { queue.borrow_mut().push_back(Action::Move(dir)); push_delay(queue, MOVE_COST); } }); // delay(secs): insert an explicit pause into the queue, merged with any trailing Delay. let q = queues.clone(); engine.register_fn("delay", move |ctx: NativeCallContext, secs: f64| { let src = source_of(&ctx); if let Some(queue) = q.borrow().get(&src) { push_delay(queue, secs.max(0.0)); } }); // now(): pop the back of the queue and push it to the front. // Use after a zero-cost action (say, log) to bypass any pending Delay. let q = queues.clone(); engine.register_fn("now", move |ctx: NativeCallContext| { let src = source_of(&ctx); if let Some(queue) = q.borrow().get(&src) { let mut q = queue.borrow_mut(); if let Some(back) = q.pop_back() { q.push_front(back); } } }); let q = queues.clone(); engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: i64| { emit(&q, source_of(&ctx), Action::SetTile(tile as u32)); }); let q = queues.clone(); engine.register_fn( "log", move |ctx: NativeCallContext, msg: ImmutableString| { emit( &q, source_of(&ctx), Action::Log(LogLine::raw(msg.to_string())), ); }, ); let q = queues.clone(); engine.register_fn( "say", move |ctx: NativeCallContext, msg: ImmutableString| { emit(&q, source_of(&ctx), Action::Say(msg.to_string())); }, ); // set_tag(target_id, tag, present): add (true) or remove (false) a tag on any object. // Use MY_ID as target_id to mutate the calling object's own tags. let q = queues.clone(); engine.register_fn( "set_tag", move |ctx: NativeCallContext, target: i64, tag: ImmutableString, present: bool| { emit( &q, source_of(&ctx), Action::SetTag { target: target as ObjectId, tag: tag.to_string(), present, }, ); }, ); } /// Registers the `Queue` object: a handle to an object's output queue with `length()` /// and `clear()`. Safe to mutate from a script because the host only touches these /// queues between script calls (during [`ScriptHost::pump`]). fn register_queue_api(engine: &mut Engine) { engine.register_type_with_name::("Queue"); engine.register_fn("length", |q: &mut ObjQueue| q.borrow().len() as i64); engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear()); } /// Appends a [`Action::Delay`] to the back of `queue`, merging with an existing /// trailing delay to prevent adjacent delays from accumulating. fn push_delay(queue: &ObjQueue, secs: f64) { let mut q = queue.borrow_mut(); if let Some(Action::Delay(r)) = q.back_mut() { *r += secs; } else { q.push_back(Action::Delay(secs)); } } /// Appends `action` to the output queue of the object identified by `source`. fn emit(queues: &QueueMap, source: ObjectId, action: Action) { if let Some(queue) = queues.borrow().get(&source) { queue.borrow_mut().push_back(action); } } /// Reads the issuing object's [`ObjectId`] from the call tag (set in [`ScriptHost::run`]). /// /// Falls back to `0` (never a valid id, since ids start at 1) if the tag is missing /// or out of range, which makes [`emit`] a harmless no-op for an unknown source. fn source_of(ctx: &NativeCallContext) -> ObjectId { ctx.tag() .and_then(|t| t.as_int().ok()) .and_then(|n| ObjectId::try_from(n).ok()) .unwrap_or(0) } /// Builds a fresh per-object scope, seeded with the read-only `Board` view, this /// object's `Queue`, the four direction constants, and `MY_ID` (the object's own /// stable [`ObjectId`] as `i64`, for use with `set_tag` and similar calls). fn new_object_scope(board: &BoardRef, queue: &ObjQueue, object_id: ObjectId) -> Scope<'static> { let mut scope = Scope::new(); scope.push_constant("Board", board.clone()); scope.push_constant("Queue", queue.clone()); scope.push_constant("North", Direction::North); scope.push_constant("South", Direction::South); scope.push_constant("East", Direction::East); scope.push_constant("West", Direction::West); scope.push_constant("MY_ID", object_id as i64); scope }