//! 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(me, state)` — run once after the whole map is loaded (see [`ScriptHost::run_init`]). //! - `tick(me, state, dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]). //! - `bump(me, dir)` — run when a solid (the player, an object, or a pushed crate) presses into this //! object's cell, with the [`Direction`] the bump came *from*; see [`ScriptHost::run_bump`]. //! - `grab(me, state)` — run when the player walks onto a `grab` object; see [`ScriptHost::run_grab`]. //! Typically adds a stat + `die()`s. //! //! | Name | Type | Description | //! |---|---|---| //! | `Registry` | `Registry` | Board-scoped key→value store persisting across board transitions. | //! //! Direction and color constants are registered as a global Rhai module and //! are visible to all functions at any call depth, including Rhai-to-Rhai calls: //! //! | Name | Type | Description | //! |---|---|---| //! | `North`/`South`/`East`/`West` | `Direction` | Cardinal directions. | //! | `Black`..`White` | `String` | 16 EGA/VGA color hex strings. | //! //! ## Write functions //! //! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`, //! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`, //! `send(target_id, fn_name [, arg])`, `teleport(id, x, y)`, `push(x, y, dir)`, //! `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()` use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST}; use crate::game::SAY_DURATION; use crate::log::LogLine; use crate::map_file::parse_color; use crate::object_def::ObjectDef; use crate::utils::{Direction, ErrorSink, Hook, ObjectId}; use rhai::{ Array, CallFnOptions, Dynamic, Engine, ImmutableString, Module, NativeCallContext, Scope, AST, }; use std::collections::{HashMap, HashSet}; use crate::api::board::BoardRef; use crate::api::object_info::ObjectInfo; use crate::api::player::PlayerWithPos; use crate::api::queue::ObjQueue; use crate::api::registry::Registry; use crate::glyph::Glyph; use crate::keys::Keyring; use crate::player::PlayerRef; /// Types which can be registered to be sent to Rhai pub trait Registerable { /// Register this type and relevant getters / setters with a Rhai engine fn register(engine: &mut Engine, error_sink: ErrorSink); } /// A compiled script plus which lifecycle hooks it defines. struct CompiledScript { ast: AST, has_init: bool, has_tick: bool, has_bump: bool, has_grab: bool, } impl CompiledScript { pub fn has(&self, hook: Hook) -> bool { match hook { Hook::Init => self.has_init, Hook::Tick => self.has_tick, Hook::Grab => self.has_grab, Hook::Bump => self.has_bump } } } /// The compile-key for an object's script: the object's `script_name` — a /// world-pool name for a named script, or a synthetic `BUILTIN_*` name set by /// [`Board::expand_builtin_archetypes`](crate::board::Board::expand_builtin_archetypes) /// for an expanded built-in (so identical built-ins share one compiled AST, /// while the source still comes from `builtin_script`). `None` if the object has /// no script. fn script_key(obj: &ObjectDef) -> Option { obj.script_name.clone() } // ── ScriptHost ──────────────────────────────────────────────────────────────── /// Owns the Rhai engine and per-object script state for a board. pub struct ScriptHost { engine: Engine, scripts: HashMap, scopes: HashMap>, errors: ErrorSink, board: BoardRef } impl ScriptHost { /// Builds a host for the board behind `board_cell`: registers the full script 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; no script is run here. /// /// `scripts` is the world-level script pool (script name → Rhai source); it is /// read only during construction and not retained afterward. pub fn new(board_ref: BoardRef, player: PlayerRef, script_sources: &HashMap) -> Self { let errors = ErrorSink::new(); let mut scopes = HashMap::new(); let mut engine = Engine::new(); PlayerWithPos::register(&mut engine, errors.clone()); Keyring::register(&mut engine, errors.clone()); BoardRef::register(&mut engine, errors.clone()); ObjectInfo::register(&mut engine, errors.clone()); Glyph::register(&mut engine, errors.clone()); ObjQueue::register(&mut engine, errors.clone()); Registry::register(&mut engine, errors.clone()); register_write_api(&mut engine, board_ref.clone()); register_global_constants(&mut engine, board_ref.clone(), player.clone()); let board = board_ref.borrow(); // Compile each referenced script once, keyed by `script_key` (the object's // `script_name`: a world-pool name for named scripts, or a synthetic // `BUILTIN_*` name for built-ins so identical ones share one AST). The // source for a built-in still comes from its embedded `builtin_script`. let mut scripts: HashMap = HashMap::new(); let mut failed: HashSet = HashSet::new(); for obj in board.objects.values() { let Some(key) = script_key(obj) else { continue; }; if scripts.contains_key(&key) || failed.contains(&key) { continue; } // Source: the embedded built-in, or a lookup in the world script pool. let source: &str = if let Some(src) = obj.builtin_script { src } else { match script_sources.get(&key) { Some(src) => src, None => { failed.insert(key.clone()); errors.error(format!("object references unknown script '{key}'")); continue; } } }; match engine.compile(source) { Ok(ast) => { let defines = |n: &str, params: usize| { ast.iter_functions() .any(|f| f.name == n && f.params.len() == params) }; scripts.insert( key, CompiledScript { has_init: defines("init", 1), has_tick: defines("tick", 2), has_bump: defines("bump", 2), has_grab: defines("grab", 1), ast, }, ); } Err(err) => { failed.insert(key.clone()); errors.error(format!("script '{key}' failed to compile: {err}")); } } } // One runtime per object whose script compiled. for (&id, obj) in board.objects.iter() { let Some(key) = script_key(obj) else { continue; }; if !scripts.contains_key(&key) { continue; } scopes.insert(id, Scope::new()); } drop(board); Self { engine, scripts, errors, scopes, board: board_ref } } /// Runs the `tick(me, dt)` hook on one object and returns the actions it /// drained (see [`run_hook_on_one`](ScriptHost::run_hook_on_one)). pub(crate) fn run_tick_on(&mut self, id: ObjectId, dt: f64) -> Vec { self.run_hook_on(Hook::Tick, id, dt) } /// Runs the `init(me)` hook on one object and returns the actions it drained. pub(crate) fn run_init_on(&mut self, id: ObjectId) -> Vec { self.run_hook_on(Hook::Init, id, 0.0) } /// Runs `hook` on the single object `id` and returns the actions it drained. /// /// The `dt` arg is only meaningful for `Tick` (it becomes the hook's `dt` /// parameter and paces the object's delay draining); pass `0.0` otherwise. pub(crate) fn run_hook_on(&mut self, hook: Hook, id: ObjectId, dt: f64) -> Vec { let arg = match hook { Hook::Tick => Some(Dynamic::from(dt)), _ => None, }; self.run_hook_on_one(hook, id, arg, dt) } /// Calls one lifecycle `hook` on object `id` (if the script defines it), then /// drains that object's ready actions into a fresh `Vec` and returns them — /// [`GameState`](crate::game::GameState) applies them immediately, before the /// next object runs, so each object sees the board state its actions run against. fn run_hook_on_one(&mut self, hook: Hook, id: ObjectId, arg: Option, dt: f64) -> Vec { let mut actions = Vec::new(); if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) { if let Some(script_key) = info.script_name.as_ref() && let Some(script) = self.scripts.get(script_key) && let Some(scope) = self.scopes.get_mut(&id) { if script.has(hook) { let mut args = vec![Dynamic::from(info.clone())]; if let Some(d) = arg { args.push(d) } // Call this with opts tagging this call as our id. The write API will read // that to find what queue to emit events to if let Err(err) = self.engine.call_fn_with_options::<()>( CallFnOptions::default().with_tag(id as i64), scope, &script.ast, hook.to_str(), args, ) { self.errors.error(format!("script '{}' {} error: {err}", script_key, hook)); } } // Run the drain regardless of if we have the hook, otherwise // things with no tick will never advance past a delay info.drain(&mut actions, dt) } } else { unreachable!("Object not found"); } actions } /// Calls `bump(dir)` on the object with [`ObjectId`] `id`, if it defines the /// hook, and returns the actions it drained. `dir` is the [`Direction`] the /// bump came *from* (it points from the bumped object toward the bumper). pub(crate) fn run_bump(&mut self, id: ObjectId, dir: Direction) -> Vec { self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0) } /// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the /// hook, and returns the actions it drained. /// /// Fired when the player walks onto a grab object (see /// [`GameState`](crate::game::GameState)). The hook typically increments a /// player stat and removes the object via `die()`. pub(crate) fn run_grab(&mut self, object_id: ObjectId) -> Vec { self.run_hook_on_one(Hook::Grab, object_id, None, 0.0) } /// Calls the named function on the object with [`ObjectId`] `target_id`. /// /// What we pass depends on arity, in this order: /// - If it has arity 2, we pass `[ObjectInfo, arg]` /// - If it has arity 1, we pass the ObjectInfo alone /// - If it has arity 0, we pass nothing /// /// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT`. /// Returns the actions the call drained. pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) -> Vec { let mut actions = Vec::new(); if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) { if let Some(script_key) = info.script_name.as_ref() && let Some(script) = self.scripts.get(script_key) && let Some(scope) = self.scopes.get_mut(&id) { // Find the function: let arities: HashSet<_> = script.ast.iter_functions().filter_map(|f| { if f.name == fn_name { Some(f.params.len()) } else { None } }).collect(); // If it's not there at all, just bail: if arities.is_empty() { self.errors.error(format!("script '{}' send({}) error: function not found", script_key, fn_name)); return actions; } // Assemble the args let mut args = vec![]; if arities.contains(&2) { args.push(Dynamic::from(info.clone())); args.push(arg.into()); } else if arities.contains(&1) { args.push(Dynamic::from(info.clone())); } // Call this with opts tagging this call as our id. The write API will read // that to find what queue to emit events to if let Err(err) = self.engine.call_fn_with_options::<()>( CallFnOptions::default().with_tag(id as i64), scope, &script.ast, fn_name, args, ) { self.errors.error(format!("script '{}' send({}) error: {err}", script_key, fn_name)); } info.drain(&mut actions, 0.0) } } else { unreachable!("Object id not found, tried to send"); } actions } /// Removes and returns the errors collected since the last drain. pub fn take_errors(&mut self) -> Vec { self.errors.take() } } // ── Write API ───────────────────────────────────────────────────────────────── fn register_write_api(engine: &mut Engine, board: BoardRef) { engine.register_type_with_name::("Direction"); // Rhai does not auto-derive comparison for custom types, so register `==`/`!=` // to let scripts test the `bump` direction (e.g. `if dir == West { … }`). engine.register_fn("==", |a: Direction, b: Direction| a == b); engine.register_fn("!=", |a: Direction, b: Direction| a != b); // Let a Direction interpolate/print as its name (e.g. in `log(`from ${dir}`)`). let dir_name = |d: Direction| match d { Direction::North => "North", Direction::South => "South", Direction::East => "East", Direction::West => "West", }; engine.register_fn("to_string", dir_name); engine.register_fn("to_debug", dir_name); // Expose the unit-step components so scripts can turn a direction into a cell // offset (e.g. the bumper's cell is `(me.x + dir.dx, me.y + dir.dy)`). engine.register_get("dx", |d: &mut Direction| d.dx()); engine.register_get("dy", |d: &mut Direction| d.dy()); // move(dir): enqueue a Move followed by a rate-limiting Delay. let b = board.clone(); engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { let src = source_of(&ctx); if let Some(def) = b.borrow_mut().objects.get_mut(&src) { def.queue.act(Action::Move(dir)); def.queue.delay(MOVE_COST); } }); let b = board.clone(); engine.register_fn("delay", move |ctx: NativeCallContext, dt: Dynamic| { let src = source_of(&ctx); if let Some(def) = b.borrow_mut().objects.get_mut(&src) && let Ok(dt) = dt.as_float() { def.queue.delay(dt); } }); let b = board.clone(); engine.register_fn("now", move |ctx: NativeCallContext| { let src = source_of(&ctx); if let Some(def) = b.borrow_mut().objects.get_mut(&src) { def.queue.now() } }); let b = board.clone(); engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: i64| { emit(&b, source_of(&ctx), Action::SetTile(tile as u32)); }); // alter_gems(n): change the player's gem count (negative subtracts; clamped at 0). let b = board.clone(); engine.register_fn("alter_gems", move |ctx: NativeCallContext, n: i64| { emit(&b, source_of(&ctx), Action::AddGems(n)); }); // alter_health(dh): change the player's health by dh (clamped to [0, max_health]). let b = board.clone(); engine.register_fn("alter_health", move |ctx: NativeCallContext, dh: i64| { emit(&b, source_of(&ctx), Action::AlterHealth(dh)); }); // set_key(color, present): give (true) or take (false) the named key color. let b = board.clone(); engine.register_fn( "set_key", move |ctx: NativeCallContext, color: ImmutableString, present: bool| { emit(&b, source_of(&ctx), Action::SetKey(color.into(), present)); }, ); // die(): remove the calling object from the board. let b = board.clone(); engine.register_fn("die", move |ctx: NativeCallContext| { emit(&b, source_of(&ctx), Action::Die); }); let b = board.clone(); engine.register_fn( "log", move |ctx: NativeCallContext, msg: ImmutableString| { let id = source_of(&ctx); emit(&b, id, Action::Log(LogLine::raw(msg.to_string()))); }, ); let b = board.clone(); engine.register_fn( "say", move |ctx: NativeCallContext, msg: ImmutableString| { emit( &b, source_of(&ctx), Action::Say(msg.to_string(), SAY_DURATION), ); }, ); let b = board.clone(); engine.register_fn( "say", move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| { emit(&b, source_of(&ctx), Action::Say(msg.to_string(), dur)); }, ); // scroll(lines): open a full-screen scrollable overlay. Each element of `lines` // is either a plain string (text) or a 2-element array [choice_key, display_text]. let b = board.clone(); engine.register_fn("scroll", move |ctx: NativeCallContext, arr: Array| { let lines = arr .into_iter() .filter_map(|item| { if let Ok(s) = item.clone().into_string() { Some(ScrollLine::Text(s)) } else { let pair: Vec = item.into_array().ok()?; if pair.len() == 2 { let choice = pair[0].clone().into_string().ok()?; let display = pair[1].clone().into_string().ok()?; Some(ScrollLine::Choice { choice, display }) } else { None } } }) .collect(); emit(&b, source_of(&ctx), Action::Scroll(lines)); }); let b = board.clone(); engine.register_fn( "set_tag", move |ctx: NativeCallContext, target: i64, tag: ImmutableString, present: bool| { emit(&b, source_of(&ctx), Action::SetTag { target: target as ObjectId, tag: tag.to_string(), present }) } ); let b = board.clone(); engine.register_fn( "set_tag", move |ctx: NativeCallContext, target: ObjectId, tag: ImmutableString, present: bool| { emit(&b, source_of(&ctx), Action::SetTag { target, tag: tag.to_string(), present }) } ); // set_fg(fg): change foreground color only. let b = board.clone(); engine.register_fn( "set_fg", move |ctx: NativeCallContext, fg: ImmutableString| { emit( &b, source_of(&ctx), Action::SetColor { fg: Some(parse_color(fg.as_str())), bg: None, }, ); }, ); // set_bg(bg): change background color only. let b = board.clone(); engine.register_fn( "set_bg", move |ctx: NativeCallContext, bg: ImmutableString| { emit( &b, source_of(&ctx), Action::SetColor { fg: None, bg: Some(parse_color(bg.as_str())), }, ); }, ); // set_color(fg, bg): change both colors. let b = board.clone(); engine.register_fn( "set_color", move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| { emit( &b, source_of(&ctx), Action::SetColor { fg: Some(parse_color(fg.as_str())), bg: Some(parse_color(bg.as_str())), }, ); }, ); // send(target, fn_name): call a named function on another object. let b = board.clone(); engine.register_fn( "send", move |ctx: NativeCallContext, target: i64, name: ImmutableString| { emit( &b, source_of(&ctx), Action::Send { target: target as ObjectId, fn_name: name.to_string(), arg: SendArg::None, }, ); }, ); // send(target, fn_name, arg): same, with an optional string or number argument. let b = board.clone(); engine.register_fn( "send", move |ctx: NativeCallContext, target: i64, name: ImmutableString, arg: Dynamic| { emit( &b, source_of(&ctx), Action::Send { target: target as ObjectId, fn_name: name.to_string(), arg: arg.into(), }, ); }, ); // teleport(target, x, y): move object `target` to an arbitrary cell (pass your // own `me.id` to move yourself; `target == -1` moves the player). Zero time // cost. Blocked (with an error logged at resolve time) if that entity is solid // and the destination already holds a different solid occupant. let b = board.clone(); engine.register_fn("teleport", move |ctx: NativeCallContext, target: i64, x: i64, y: i64| { emit(&b, source_of(&ctx), Action::Teleport { target, x, y }); }); // push(x, y, dir): shove the pushable chain at (x, y) one step in dir. Acts on // arbitrary cells, not the caller, and adds no delay (zero time cost). let b = board.clone(); engine.register_fn( "push", move |ctx: NativeCallContext, x: i64, y: i64, dir: Direction| { emit(&b, source_of(&ctx), Action::Push { x, y, dir, }); } ); // shift([[x, y], ...): Emits a shift action which moves the contents of each given cell in // a loop (the last cell is moved to the first coord). Doesn't move things that aren't pushable, // and won't move anything into a cell that's not vacant (or vacated by this shift). let b = board.clone(); engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| { let src = source_of(&ctx); match read_coord_array(&arr) { Ok(pairs) => emit(&b, src, Action::Shift(pairs)), Err(_) => emit(&b, src, Action::Log(LogLine::error("shift: each entry must be [x, y]".to_string()))) } }); } /// Read a `Vec<(i32, i32)>` from a Rhai array, to receive a list of coordinates from a script. /// Returns Err if the array isn't `[[x, y], ...]` fn read_coord_array(arr: &Array) -> Result, ()> { let mut pairs = Vec::new(); for elem in arr { let coords: Option> = elem.read_lock::().and_then(|inner| { inner.iter().map(|v| v.as_int().ok()).collect() }); if let Some(coords) = coords && coords.len() == 2 { pairs.push((coords[0], coords[1])) } else { return Err(()); } } Ok(pairs) } // ── Scope construction & global constants ───────────────────────────────────── /// Registers direction and color constants as a global Rhai module so they are /// visible to every function at any call depth, including Rhai-to-Rhai calls. /// (Scope-level constants are only visible to the top-level function Rust calls.) fn register_global_constants(engine: &mut Engine, board: BoardRef, player: PlayerRef) { let mut m = Module::new(); m.set_var("Board", board.clone()); m.set_var("Player", PlayerWithPos(player.clone(), board)); m.set_var("North", Direction::North); m.set_var("South", Direction::South); m.set_var("East", Direction::East); m.set_var("West", Direction::West); // 16 EGA/VGA color constants as "#RRGGBB" strings, from the shared palette table // (the single source of truth, also used by the editor's color picker). for (name, c) in crate::colors::NAMED_COLORS { m.set_var(name, format!("#{:02X}{:02X}{:02X}", c.r, c.g, c.b)); } engine.register_global_module(m.into()); } /// Appends `action` to the output queue of the object identified by `source`. fn emit(board: &BoardRef, source: ObjectId, action: Action) { if let Some(def) = board.borrow_mut().objects.get_mut(&source) { def.queue.act(action); } } /// Reads the issuing object's [`ObjectId`] from the call tag. 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) }