//! 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`]. //! //! ## Script constants //! //! Object-specific handles live in the per-object scope (visible to the //! top-level hook Rust calls): //! //! | Name | Type | Description | //! |---|---|---| //! | `Board` | `Board` | Read-only world handle. | //! | `Queue` | `Queue` | This object's pending-action queue. | //! | `Me` | `Me` | Self-reference (id, name, x, y, tags, glyph). | //! | `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. | //! //! ## Board read API (`Board.*`) //! //! - `Board.player_x / player_y / width / height` — world properties //! - `Board.tagged(tag) -> Array[ObjectInfo]` — objects carrying a tag //! - `Board.named(name) -> ObjectInfo | ()` — object with that name (or unit) //! - `Board.get(id) -> ObjectInfo | ()` — look up by id; `-1` returns player info //! //! ## Self-reference API (`Me.*`) //! //! - `Me.id / name / x / y` — getters //! - `Me.has_tag(s) -> bool` //! - `Me.tags() -> Array` //! - `Me.glyph() -> Glyph` (`Glyph.tile`, `Glyph.fg`, `Glyph.bg`) //! //! ## 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(x, y)` //! //! ## Queue API //! //! `Queue.length()`, `Queue.clear()`, `Queue.peek()`, `Queue.pop()` use crate::action::{Action, MOVE_COST, ScrollLine, action_to_map}; use crate::board::Board; use crate::game::SAY_DURATION; use crate::log::LogLine; use crate::map_file::{color_to_hex, parse_color}; use crate::object_def::ObjectDef; use crate::utils::{Direction, ObjectId, RegistryValue, ScriptArg}; use rhai::{ AST, Array, CallFnOptions, Dynamic, Engine, FuncArgs, ImmutableString, Module, NativeCallContext, Scope, }; use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; /// An action promoted from an object's output queue onto the board queue, tagged /// with the object that issued it. pub(crate) struct BoardAction { /// The stable [`ObjectId`] of the object that issued the action. pub(crate) source: ObjectId, /// The action to apply. pub(crate) action: Action, } /// A read-only handle to the world, exposed to scripts as `Board`. type BoardRef = Rc>; /// A single object's output queue. type ObjQueue = Rc>>; /// The board queue: actions promoted from object queues, awaiting resolution. type BoardQueue = Rc>>; /// Maps an [`ObjectId`] to its output queue. type QueueMap = Rc>>; /// Channel for engine/compile/runtime errors. type ErrorSink = Rc>>; /// A compiled script plus which lifecycle hooks it defines. struct CompiledScript { ast: AST, has_init: bool, has_tick: bool, has_bump: bool, } /// The runtime state of one scripted object. struct ObjectRuntime { object_id: ObjectId, /// Key into [`ScriptHost::scripts`] for this object's compiled script — the /// pool name for a named script, or the source text for an embedded built-in. script_key: String, scope: Scope<'static>, output_queue: ObjQueue, } /// The compile-key for an object's script: the embedded source itself for a /// built-in (so identical built-ins share one compiled AST), or the world-pool /// name for a named script. `None` if the object has no script. fn script_key(obj: &ObjectDef) -> Option { if let Some(src) = obj.builtin_script { Some(src.to_string()) } else { obj.script_name.clone() } } // ── Rhai types exposed to scripts ──────────────────────────────────────────── /// A snapshot of one board object, returned by `Board.tagged`, `Board.named`, /// and `Board.get`. Passed by value — scripts read fields, not a live reference. #[derive(Clone)] struct ObjectInfo { id: i64, x: i64, y: i64, name: Option, tags: Vec, } /// The glyph (tile + colors) of a board object, returned by `Me.glyph()`. /// Colors are `"#RRGGBB"` hex strings matching the map-file format. #[derive(Clone)] struct RhaiGlyph { tile: i64, fg: String, bg: String, } /// A script's read-only reference to itself, pushed into scope as the constant `Me`. /// Holds the object id and a shared board handle so getters can look up live values. #[derive(Clone)] struct Me { object_id: ObjectId, board: BoardRef, } /// The board's script registry, pushed into scope as the constant `Registry`. /// `get`/`set`/`get_or` methods let scripts read and write per-board key→value pairs /// that persist across board transitions. Mutation goes through the inner /// `Rc>`, so the struct itself can be shared as a scope constant. #[derive(Clone)] struct Registry { board: BoardRef, } // ── ObjectInfo helper ───────────────────────────────────────────────────────── fn object_info_from(id: ObjectId, board: &Board) -> Option { let obj = board.objects.get(&id)?; Some(ObjectInfo { id: id as i64, x: obj.x as i64, y: obj.y as i64, name: obj.name.clone(), tags: obj.tags.iter().cloned().collect(), }) } fn object_info_player(board: &Board) -> ObjectInfo { ObjectInfo { id: -1, x: board.player.x as i64, y: board.player.y as i64, name: None, tags: Vec::new(), } } // ── ScriptHost ──────────────────────────────────────────────────────────────── /// Owns the Rhai engine and per-object script state for a board. pub struct ScriptHost { engine: Engine, scripts: HashMap, objects: Vec, board_queue: BoardQueue, errors: ErrorSink, } 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_cell: &BoardRef, script_sources: &HashMap) -> 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, &errors); register_write_api(&mut engine, &queues); register_queue_api(&mut engine); register_me_type(&mut engine); register_object_info_type(&mut engine); register_glyph_type(&mut engine); register_registry_type(&mut engine); register_global_constants(&mut engine); let board = board_cell.borrow(); // Compile each referenced script once, keyed by `script_key` (pool name for // named scripts, source text for embedded built-ins so identical ones share // one AST). 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.borrow_mut().push(LogLine::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", 0), has_tick: defines("tick", 1), has_bump: defines("bump", 1), ast, }, ); } Err(err) => { failed.insert(key.clone()); errors.borrow_mut().push(LogLine::error(format!( "script '{key}' failed to compile: {err}" ))); } } } // One runtime per object whose script compiled. let mut objects = Vec::new(); for (&id, obj) in board.objects.iter() { let Some(key) = script_key(obj) else { continue; }; if !scripts.contains_key(&key) { continue; } let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new())); queues.borrow_mut().insert(id, queue.clone()); objects.push(ObjectRuntime { object_id: id, script_key: key, 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. 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 queues. 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. After the hook, drains the object's queue with `dt = 0`. pub fn run_bump(&mut self, object_id: ObjectId, bumper: i64) { 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_key) 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::error(format!( "script '{}' bump error: {err}", obj.script_key ))); } } self.drain(i, 0.0); } /// Calls the named function on the object with [`ObjectId`] `target_id`. /// /// If `arg` is `Some` and the function accepts one parameter, the arg is passed. /// If the function accepts zero parameters (or arg is `None`), it is called with /// no args. If neither arity exists, the call is silently skipped. /// After the call, drains the object's queue with `dt = 0`. pub(crate) fn run_send(&mut self, target_id: ObjectId, fn_name: &str, arg: Option) { let Some(i) = self.objects.iter().position(|o| o.object_id == target_id) else { return; }; { let Self { engine, scripts, objects, errors, .. } = self; let obj = &mut objects[i]; let Some(compiled) = scripts.get(&obj.script_key) else { return; }; let has_1 = compiled .ast .iter_functions() .any(|f| f.name == fn_name && f.params.len() == 1); let has_0 = compiled .ast .iter_functions() .any(|f| f.name == fn_name && f.params.is_empty()); let options = CallFnOptions::default().with_tag(obj.object_id as i64); let result = if has_1 { // Call with arg (or unit if arg is absent). let dyn_arg: Dynamic = match &arg { Some(ScriptArg::Str(s)) => Dynamic::from(s.clone()), Some(ScriptArg::Num(n)) => Dynamic::from(*n), None => Dynamic::UNIT, }; engine.call_fn_with_options::<()>( options, &mut obj.scope, &compiled.ast, fn_name, (dyn_arg,), ) } else if has_0 { engine.call_fn_with_options::<()>( options, &mut obj.scope, &compiled.ast, fn_name, (), ) } else { return; // Function not defined on this object }; if let Err(err) = result { errors.borrow_mut().push(LogLine::error(format!( "script '{}' send('{}') error: {err}", obj.script_key, fn_name ))); } } self.drain(i, 0.0); } /// Removes and returns the actions promoted onto the board queue. pub(crate) 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`. 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(); let is_delay = matches!(queue.borrow().front(), Some(Action::Delay(_))); if is_delay { let expired = { let mut q = queue.borrow_mut(); let Some(Action::Delay(r)) = q.front_mut() else { break; }; *r -= dt; dt = 0.0; *r <= 0.0 }; if expired { queue.borrow_mut().pop_front(); } else { break; } } 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`. fn run( &mut self, hook: &str, defined: fn(&CompiledScript) -> bool, args: A, drain_dt: f64, ) { for i in 0..self.objects.len() { { let Self { engine, scripts, objects, errors, .. } = self; let obj = &mut objects[i]; if let Some(compiled) = scripts.get(&obj.script_key) && 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::error(format!( "script '{}' {hook} error: {err}", obj.script_key ))); } } } self.drain(i, drain_dt); } } } // ── Register Me type ────────────────────────────────────────────────────────── fn register_me_type(engine: &mut Engine) { engine.register_type_with_name::("Me"); engine.register_get("id", |me: &mut Me| me.object_id as i64); engine.register_get("name", |me: &mut Me| { me.board .borrow() .objects .get(&me.object_id) .and_then(|o| o.name.as_deref()) .unwrap_or("") .to_string() }); engine.register_get("x", |me: &mut Me| { me.board .borrow() .objects .get(&me.object_id) .map(|o| o.x as i64) .unwrap_or(0) }); engine.register_get("y", |me: &mut Me| { me.board .borrow() .objects .get(&me.object_id) .map(|o| o.y as i64) .unwrap_or(0) }); engine.register_fn("has_tag", |me: &mut Me, tag: ImmutableString| { me.board .borrow() .objects .get(&me.object_id) .is_some_and(|o| o.tags.contains(tag.as_str())) }); engine.register_fn("tags", |me: &mut Me| -> rhai::Array { me.board .borrow() .objects .get(&me.object_id) .map(|o| o.tags.iter().map(|t| Dynamic::from(t.clone())).collect()) .unwrap_or_default() }); engine.register_fn("glyph", |me: &mut Me| -> RhaiGlyph { let board = me.board.borrow(); if let Some(obj) = board.objects.get(&me.object_id) { RhaiGlyph { tile: obj.glyph.tile as i64, fg: color_to_hex(obj.glyph.fg), bg: color_to_hex(obj.glyph.bg), } } else { RhaiGlyph { tile: 0, fg: "#000000".to_string(), bg: "#000000".to_string(), } } }); } // ── Register Registry type ──────────────────────────────────────────────────── fn register_registry_type(engine: &mut Engine) { engine.register_type_with_name::("Registry"); // Registry.get(key) -> Dynamic — returns () if the key is absent. engine.register_fn("get", |r: &mut Registry, key: ImmutableString| -> Dynamic { r.board .borrow() .registry .get(key.as_str()) .map(|v| v.clone().into()) .unwrap_or(Dynamic::UNIT) }); // Registry.set(key, value) — stores a primitive value; () removes the key; // unsupported types (closures, custom objects) are silently ignored. engine.register_fn( "set", |r: &mut Registry, key: ImmutableString, value: Dynamic| { match RegistryValue::try_from(value.clone()) { Ok(rv) => { r.board.borrow_mut().registry.insert(key.to_string(), rv); } Err(()) if value.is_unit() => { r.board.borrow_mut().registry.remove(key.as_str()); } Err(()) => {} // unsupported type — silently ignore } }, ); // Registry.get_or(key, default) — returns the stored value when present and the // same Rhai type as `default`; falls back to `default` otherwise. engine.register_fn( "get_or", |r: &mut Registry, key: ImmutableString, default: Dynamic| -> Dynamic { if let Some(stored) = r.board.borrow().registry.get(key.as_str()).cloned() { let candidate: Dynamic = stored.into(); // Only return the stored value if it matches the type of the default. if candidate.type_id() == default.type_id() { candidate } else { default } } else { default } }, ); } // ── Register ObjectInfo type ────────────────────────────────────────────────── fn register_object_info_type(engine: &mut Engine) { engine.register_type_with_name::("ObjectInfo"); engine.register_get("id", |o: &mut ObjectInfo| o.id); engine.register_get("x", |o: &mut ObjectInfo| o.x); engine.register_get("y", |o: &mut ObjectInfo| o.y); engine.register_get("name", |o: &mut ObjectInfo| { o.name.as_deref().unwrap_or("").to_string() }); engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array { o.tags.iter().map(|t| Dynamic::from(t.clone())).collect() }); } // ── Register RhaiGlyph type ─────────────────────────────────────────────────── fn register_glyph_type(engine: &mut Engine) { engine.register_type_with_name::("Glyph"); engine.register_get("tile", |g: &mut RhaiGlyph| g.tile); engine.register_get("fg", |g: &mut RhaiGlyph| g.fg.clone()); engine.register_get("bg", |g: &mut RhaiGlyph| g.bg.clone()); } // ── Board read API ──────────────────────────────────────────────────────────── fn register_read_api( engine: &mut Engine, board: &BoardRef, board_queue: &BoardQueue, errors: &ErrorSink, ) { 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); // blocked(dir) — true if moving in dir would be impossible for the calling object. 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) }); // Board.tagged(tag) -> Array[ObjectInfo] let b = board.clone(); engine.register_fn( "tagged", move |_board: &mut BoardRef, tag: ImmutableString| -> rhai::Array { let board = b.borrow(); board .objects .iter() .filter(|(_, o)| o.tags.contains(tag.as_str())) .filter_map(|(&id, _)| object_info_from(id, &board)) .map(Dynamic::from) .collect() }, ); // Board.named(name) -> ObjectInfo | () let b = board.clone(); engine.register_fn( "named", move |_board: &mut BoardRef, name: ImmutableString| -> Dynamic { let board = b.borrow(); board .objects .iter() .find(|(_, o)| o.name.as_deref() == Some(name.as_str())) .and_then(|(&id, _)| object_info_from(id, &board)) .map(Dynamic::from) .unwrap_or(Dynamic::UNIT) }, ); // Board.get(id) -> ObjectInfo | () (-1 returns player; unknown id logs error) let b = board.clone(); let errs = errors.clone(); engine.register_fn("get", move |_board: &mut BoardRef, id: i64| -> Dynamic { let board = b.borrow(); if id == -1 { return Dynamic::from(object_info_player(&board)); } if id <= 0 { errs.borrow_mut() .push(LogLine::error(format!("Board.get: invalid id {id}"))); return Dynamic::UNIT; } match object_info_from(id as ObjectId, &board) { Some(info) => Dynamic::from(info), None => { errs.borrow_mut() .push(LogLine::error(format!("Board.get: no object with id {id}"))); Dynamic::UNIT } } }); } /// Whether the object at `source` would be blocked moving in `dir`. 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; } 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 }) } // ── Write API ───────────────────────────────────────────────────────────────── 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. Two overloads so integer literals work. 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)); } }); let q = queues.clone(); engine.register_fn("delay", move |ctx: NativeCallContext, secs: i64| { let src = source_of(&ctx); if let Some(queue) = q.borrow().get(&src) { push_delay(queue, (secs.max(0)) as f64); } }); // now(): promote the most recently enqueued action to the front. 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(), SAY_DURATION), ); }, ); let q = queues.clone(); engine.register_fn( "say", move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| { emit(&q, 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 q = queues.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(&q, source_of(&ctx), Action::Scroll(lines)); }); 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, }, ); }, ); // set_fg(fg): change foreground color only. let q = queues.clone(); engine.register_fn( "set_fg", move |ctx: NativeCallContext, fg: ImmutableString| { emit( &q, source_of(&ctx), Action::SetColor { fg: Some(parse_color(fg.as_str())), bg: None, }, ); }, ); // set_bg(bg): change background color only. let q = queues.clone(); engine.register_fn( "set_bg", move |ctx: NativeCallContext, bg: ImmutableString| { emit( &q, source_of(&ctx), Action::SetColor { fg: None, bg: Some(parse_color(bg.as_str())), }, ); }, ); // set_color(fg, bg): change both colors. let q = queues.clone(); engine.register_fn( "set_color", move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| { emit( &q, 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 q = queues.clone(); engine.register_fn( "send", move |ctx: NativeCallContext, target: i64, name: ImmutableString| { emit( &q, source_of(&ctx), Action::Send { target: target as ObjectId, fn_name: name.to_string(), arg: None, }, ); }, ); // send(target, fn_name, arg): same, with an optional string or number argument. let q = queues.clone(); engine.register_fn( "send", move |ctx: NativeCallContext, target: i64, name: ImmutableString, arg: Dynamic| { let script_arg = if let Ok(n) = arg.clone().as_int() { Some(ScriptArg::Num(n as f64)) } else if let Ok(f) = arg.clone().as_float() { Some(ScriptArg::Num(f)) } else if let Ok(s) = arg.into_string() { Some(ScriptArg::Str(s)) } else { None }; emit( &q, source_of(&ctx), Action::Send { target: target as ObjectId, fn_name: name.to_string(), arg: script_arg, }, ); }, ); // teleport(x, y): move the calling object to an arbitrary cell. Zero time cost. // Blocked (with an error logged at resolve time) if the object is solid and the // destination already has a solid occupant. let q = queues.clone(); engine.register_fn("teleport", move |ctx: NativeCallContext, x: i64, y: i64| { emit( &q, source_of(&ctx), Action::Teleport { x: x as i32, y: y as i32, }, ); }); } // ── Queue API ───────────────────────────────────────────────────────────────── 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()); // peek(): front action as a Rhai map, or () if empty. engine.register_fn("peek", |q: &mut ObjQueue| -> Dynamic { q.borrow() .front() .map(|a| Dynamic::from(action_to_map(a))) .unwrap_or(Dynamic::UNIT) }); // pop(): same, but removes the front. engine.register_fn("pop", |q: &mut ObjQueue| -> Dynamic { q.borrow_mut() .pop_front() .as_ref() .map(|a| Dynamic::from(action_to_map(a))) .unwrap_or(Dynamic::UNIT) }); } // ── 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) { let mut m = Module::new(); 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()); } /// Builds a fresh per-object scope containing only the object-specific handles: /// the Board view, the object's Queue, and the Me self-reference. 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( "Me", Me { object_id, board: board.clone(), }, ); scope.push_constant( "Registry", Registry { board: board.clone(), }, ); scope } // ── Helpers ─────────────────────────────────────────────────────────────────── /// 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. 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) }