//! 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`) appends an [`Action`] to the issuing object's **output //! queue** (a per-object FIFO). Actions leave that queue one batch at a time via //! [`ScriptHost::pump`], which promotes them onto the shared **board queue**: //! //! - A [`pump`](ScriptHost::pump) pulls the leading run of zero-cost actions plus at //! most one action with a time cost (currently only [`Action::Move`], 250 ms). //! - Pulling a timed action arms the object's **ready timer**; while it is running no //! further actions are pulled (this is what caps an object's speed). [`tick`] ticks //! the timer down via [`advance_timers`](ScriptHost::advance_timers). //! //! [`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`]). //! //! [`tick`]: ScriptHost::run_tick //! [`take_board_queue`]: ScriptHost::take_board_queue //! [`GameState`]: crate::game::GameState use crate::game::Board; use crate::log::LogLine; use rhai::{AST, CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope}; use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; /// How long a move occupies an object before it can act again, in seconds. pub const MOVE_COST: f64 = 0.25; /// One deferred mutation emitted by a script, applied by [`crate::game::GameState`] /// after it is promoted onto the board queue. pub enum Action { /// Move the source object one cell in a direction (subject to passability). Move(Direction), /// Set the source object's glyph tile index. SetTile(u32), /// Append a plain (uncolored) line to the game log. Log(LogLine), } impl Action { /// How long performing this action keeps the object busy, in seconds. /// /// Only [`Action::Move`] has a cost ([`MOVE_COST`]); zero-cost actions can be /// drained several-per-pump and never arm the ready timer. pub fn time_cost(&self) -> f64 { match self { Action::Move(_) => MOVE_COST, Action::SetTile(_) | Action::Log(_) => 0.0, } } } /// An action promoted from an object's output queue onto the board queue, tagged /// with the object that issued it. pub struct BoardAction { /// The object that issued the action. // TODO: `source` is an index into `Board::objects`, which is a stopgap — it // breaks under object spawn/destroy/reorder. Replace with a monotonically // increasing unique object id, with objects stored keyed by it (e.g. // `BTreeMap`). Mirrored on `ObjectRuntime::object_index`. pub source: usize, /// The action to apply. pub action: Action, } /// A cardinal movement direction. #[derive(Clone, Copy)] pub enum Direction { North, South, East, West, } impl From for (i32, i32) { /// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down). fn from(d: Direction) -> Self { match d { Direction::North => (0, -1), Direction::South => (0, 1), Direction::East => (1, 0), Direction::West => (-1, 0), } } } /// 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 object index 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, its pending actions, and its cooldown timer. struct ObjectRuntime { /// Index into [`crate::game::Board::objects`]; passed to scripts as the /// per-call tag so host functions know which object issued an action. // TODO: see [`BoardAction::source`] — array indices should become stable ids. object_index: usize, /// 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). output_queue: ObjQueue, /// Seconds remaining before this object may pull another action. While `> 0` /// nothing is pulled from [`output_queue`](Self::output_queue). ready_timer: f64, } /// 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.iter() { 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 (i, obj) in board.objects.iter().enumerate() { 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(i, queue.clone()); objects.push(ObjectRuntime { object_index: i, script_name: name.clone(), scope: new_object_scope(board_cell, &queue), output_queue: queue, ready_timer: 0.0, }); } drop(board); Self { engine, scripts, objects, board_queue, errors, } } /// Calls `init()` on every scripted object that defines it, pumping each object's /// queue afterward. pub fn run_init(&mut self) { self.run("init", |c| c.has_init, ()); } /// Calls `tick(dt)` on every scripted object that defines it, passing the elapsed /// seconds, then pumps each object's queue. pub fn run_tick(&mut self, dt: f64) { self.run("tick", |c| c.has_tick, (dt,)); } /// Calls `bump(id)` on the object at `object_index`, if it defines the hook. /// /// `bumper` is the index of the object that moved into it, or `-1` for the player. /// Does **not** pump: any actions the hook emits wait for the next tick's pump, so /// a bump can't cascade within the same resolution pass. pub fn run_bump(&mut self, object_index: usize, bumper: i64) { let Self { engine, scripts, objects, errors, .. } = self; let Some(obj) = objects.iter_mut().find(|o| o.object_index == object_index) else { return; }; let Some(compiled) = scripts.get(&obj.script_name) else { return; }; if !compiled.has_bump { return; } let options = CallFnOptions::default().with_tag(object_index 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 ))); } } /// Counts every object's ready timer down by `dt` (floored at zero). Call once per /// frame before [`run_tick`](Self::run_tick). pub fn advance_timers(&mut self, dt: f64) { for obj in &mut self.objects { obj.ready_timer = (obj.ready_timer - dt).max(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()) } /// Promotes ready actions from object `i`'s output queue onto the board queue. /// /// While the object's ready timer is running, nothing is pulled. Otherwise the /// leading run of zero-cost actions is promoted, followed by at most one timed /// action (which arms the timer and ends the pump). The board-queue guard keeps a /// second pump in the same tick from double-promoting. fn pump(&mut self, i: usize) { let oid = self.objects[i].object_index; // Don't re-promote if this object already has actions awaiting resolution. if self.board_queue.borrow().iter().any(|ba| ba.source == oid) { return; } loop { // A running cooldown blocks all further pulls (timed or not). if self.objects[i].ready_timer > 0.0 { break; } let queue = self.objects[i].output_queue.clone(); let cost = match queue.borrow().front() { Some(action) => action.time_cost(), None => break, // queue drained }; let action = queue.borrow_mut().pop_front().expect("front checked above"); if cost > 0.0 { self.objects[i].ready_timer = cost; } self.board_queue.borrow_mut().push(BoardAction { source: oid, action, }); // A timed action ends the pump; zero-cost ones keep draining. if cost > 0.0 { break; } } } /// Shared driver for `init`/`tick`: for each object, call the hook if its script /// `defined` it (tagging the call with the object index so host functions know the /// source), then pump that object so backlogged actions drain and later objects' /// `blocked` checks can see earlier movers. Runtime errors are captured rather than /// aborting the batch. fn run( &mut self, hook: &str, defined: fn(&CompiledScript) -> bool, args: A, ) { for i in 0..self.objects.len() { // Scope the split borrow so it ends before the `pump` 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) { // The tag carries this object's index to the host functions. let options = CallFnOptions::default().with_tag(obj.object_index 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.pump(i); } } } /// 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) }); } /// 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: usize, 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` host /// functions, each appending an [`Action`] to the issuing object's output queue. fn register_write_api(engine: &mut Engine, queues: &QueueMap) { engine.register_type_with_name::("Direction"); let q = queues.clone(); engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { emit(&q, source_of(&ctx), Action::Move(dir)); }); 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())), ); }, ); } /// 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 `action` to the output queue of the object identified by `source`. fn emit(queues: &QueueMap, source: usize, action: Action) { if let Some(queue) = queues.borrow().get(&source) { queue.borrow_mut().push_back(action); } } /// Reads the issuing object's index from the call tag (set in [`ScriptHost::run`]). fn source_of(ctx: &NativeCallContext) -> usize { ctx.tag() .and_then(|t| t.as_int().ok()) .and_then(|n| usize::try_from(n).ok()) .unwrap_or(0) } /// Builds a fresh per-object scope, seeded with the read-only `Board` view, this /// object's `Queue`, and the four direction constants (`North`/`South`/`East`/`West`). fn new_object_scope(board: &BoardRef, queue: &ObjQueue) -> 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 }