//! 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 two 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`]). //! //! ## Reads vs. writes //! //! Scripts **read** the world directly through a read-only `Rc>` (type aliased //! as [`BoardRef`], registering getters only) pushed into each scope as `Board`, e.g. //! `Board.player_x`. Functions borrow it briefly per getter. //! //! Scripts **write** by enqueuing [`Command`]s: host functions (`move`, //! `set_tile`, `log`) push into a shared queue that [`GameState`] drains and //! applies *after* each batch ([`ScriptHost::take_commands`]). This keeps script //! execution from ever needing a mutable borrow of the game state, and makes //! structural/ordering effects deterministic. Which object issued a command is //! read from the per-call **tag** (set to the object index in [`ScriptHost::run`]), //! so scripts write `move(North)` without naming themselves. //! //! [`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}; use std::rc::Rc; /// Shared queue the write host functions push into; drained by [`ScriptHost::take_commands`]. type CommandQueue = Rc>>; /// A mutation requested by a script, applied by [`crate::game::GameState`] after /// the script batch finishes. pub struct Command { /// The object that issued the command. // 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, /// What the command does. pub kind: GameCommand, } /// The kinds of mutation a script can request. pub enum GameCommand { /// 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), /// A script/engine failure. Applying it logs the message for now; kept a /// distinct variant so execution can later be halted on error. Error(String), } /// 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 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, } /// The runtime state of one scripted object: which script it runs and its own /// persistent Rhai scope. struct ObjectRuntime { /// Index into [`crate::game::Board::objects`]; passed to scripts as the /// per-call tag so host functions know which object issued a command. // TODO: see [`Command::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` view + direction constants, and can /// hold object-local state in the future). Persists across calls. scope: Scope<'static>, } /// 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, /// Queue the write host functions push into; drained by [`Self::take_commands`]. commands: CommandQueue, } impl ScriptHost { /// Builds a host for the board behind `state`: registers the read/write API, /// compiles every script referenced by an object (once each), and creates a /// fresh scope per scripted object. Compile errors and references to unknown /// scripts are queued as [`GameCommand::Error`] (retrievable via /// [`Self::take_commands`]); no script is run here. pub fn new(board_cell: &BoardRef) -> Self { let commands: CommandQueue = Rc::new(RefCell::new(Vec::new())); let mut engine = Engine::new(); register_read_api(&mut engine); register_write_api(&mut engine, &commands); 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 (i, obj) in board.objects.iter().enumerate() { 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 has_init = ast .iter_functions() .any(|f| f.name == "init" && f.params.is_empty()); let has_tick = ast .iter_functions() .any(|f| f.name == "tick" && f.params.len() == 1); scripts.insert( name.clone(), CompiledScript { ast, has_init, has_tick, }, ); } Err(err) => { failed.insert(name.clone()); commands.borrow_mut().push(Command { source: i, kind: GameCommand::Error(format!( "script '{name}' failed to compile: {err}" )), }); } }, None => { failed.insert(name.clone()); commands.borrow_mut().push(Command { source: i, kind: GameCommand::Error(format!( "object references unknown script '{name}'" )), }); } } } // One runtime (independent scope) per object whose script compiled. let objects = board .objects .iter() .enumerate() .filter_map(|(i, obj)| { let name = obj.script_name.as_ref()?; scripts.contains_key(name).then(|| ObjectRuntime { object_index: i, script_name: name.clone(), scope: new_object_scope(board_cell), }) }) .collect(); drop(board); Self { engine, scripts, objects, commands, } } /// Calls `init()` on every scripted object that defines it. 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 since the last tick. pub fn run_tick(&mut self, dt: f64) { self.run("tick", |c| c.has_tick, (dt,)); } /// Removes and returns the commands queued by scripts since the last drain. pub fn take_commands(&mut self) -> Vec { std::mem::take(&mut self.commands.borrow_mut()) } /// Shared driver for the lifecycle hooks: for each object whose script /// `defined` reports the hook, call it with `args`, tagging the call with the /// object index so host functions know the command source. Runtime errors are /// captured as [`GameCommand::Error`] rather than aborting the batch. fn run( &mut self, hook: &str, defined: fn(&CompiledScript) -> bool, args: A, ) { // Split the borrow so we can call the engine while mutating each scope. let Self { engine, scripts, objects, commands, } = self; for obj in objects.iter_mut() { let Some(compiled) = scripts.get(&obj.script_name) else { continue; }; if !defined(compiled) { continue; } // 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, ) { commands.borrow_mut().push(Command { source: obj.object_index, kind: GameCommand::Error(format!( "script '{}' {hook} error: {err}", obj.script_name )), }); } } } } /// Registers the read-only `Board` API: a [`BoardRef`] type with getters that /// borrow the shared state briefly. fn register_read_api(engine: &mut Engine) { 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); } /// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log` /// host functions, each enqueuing a [`Command`] tagged with its source object. fn register_write_api(engine: &mut Engine, commands: &CommandQueue) { engine.register_type_with_name::("Direction"); let q = commands.clone(); engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { q.borrow_mut().push(Command { source: source_of(&ctx), kind: GameCommand::Move(dir), }); }); let q = commands.clone(); engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: i64| { q.borrow_mut().push(Command { source: source_of(&ctx), kind: GameCommand::SetTile(tile as u32), }); }); let q = commands.clone(); engine.register_fn( "log", move |ctx: NativeCallContext, msg: ImmutableString| { q.borrow_mut().push(Command { source: source_of(&ctx), kind: GameCommand::Log(LogLine::raw(msg.to_string())), }); }, ); } /// 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 and /// the four direction constants (`north`/`south`/`east`/`west`). fn new_object_scope(board: &BoardRef) -> Scope<'static> { let mut scope = Scope::new(); scope.push_constant("Board", board.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 }