2026-06-04 20:11:55 -05:00
|
|
|
//! Rhai scripting runtime for board objects.
|
|
|
|
|
//!
|
|
|
|
|
//! [`ScriptHost`] owns the Rhai [`Engine`], the compiled scripts referenced by a
|
2026-06-06 17:36:00 -05:00
|
|
|
//! board's objects, and a persistent per-object [`Scope`]. It drives the optional
|
2026-06-04 20:11:55 -05:00
|
|
|
//! lifecycle hooks on each scripted object:
|
|
|
|
|
//!
|
2026-06-28 00:12:52 -05:00
|
|
|
//! - `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, state, id)` — run when another mover steps into this object's cell, with the
|
2026-06-06 17:36:00 -05:00
|
|
|
//! bumper's object index (`-1` for the player); see [`ScriptHost::run_bump`].
|
2026-06-28 00:12:52 -05:00
|
|
|
//! - `grab(me, state)` — run when the player walks onto a `grab` object; see [`ScriptHost::run_grab`].
|
|
|
|
|
//! Typically adds a stat + `die()`s.
|
2026-06-04 23:52:33 -05:00
|
|
|
//!
|
2026-06-09 22:38:17 -05:00
|
|
|
//! | Name | Type | Description |
|
|
|
|
|
//! |---|---|---|
|
2026-06-13 17:58:04 -05:00
|
|
|
//! | `Registry` | `Registry` | Board-scoped key→value store persisting across board transitions. |
|
2026-06-13 15:33:04 -05:00
|
|
|
//!
|
|
|
|
|
//! 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 |
|
|
|
|
|
//! |---|---|---|
|
2026-06-09 22:38:17 -05:00
|
|
|
//! | `North`/`South`/`East`/`West` | `Direction` | Cardinal directions. |
|
|
|
|
|
//! | `Black`..`White` | `String` | 16 EGA/VGA color hex strings. |
|
2026-06-08 22:15:44 -05:00
|
|
|
//!
|
2026-06-09 22:38:17 -05:00
|
|
|
//! ## 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)`,
|
2026-06-21 01:32:47 -05:00
|
|
|
//! `send(target_id, fn_name [, arg])`, `teleport(x, y)`, `push(x, y, dir)`,
|
2026-07-05 23:41:19 -05:00
|
|
|
//! `shift([[x, y], …])`, `add_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`
|
2026-06-04 20:11:55 -05:00
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST};
|
|
|
|
|
use crate::game::SAY_DURATION;
|
2026-06-04 20:11:55 -05:00
|
|
|
use crate::log::LogLine;
|
2026-06-28 00:12:52 -05:00
|
|
|
use crate::map_file::parse_color;
|
2026-06-16 14:34:42 -05:00
|
|
|
use crate::object_def::ObjectDef;
|
2026-06-28 00:12:52 -05:00
|
|
|
use crate::utils::{Direction, ErrorSink, Hook, ObjectId, RegistryValue};
|
2026-06-15 23:35:18 -05:00
|
|
|
use rhai::{
|
2026-06-28 00:12:52 -05:00
|
|
|
Array, CallFnOptions, Dynamic, Engine, ImmutableString, Module, NativeCallContext,
|
|
|
|
|
Scope, AST,
|
2026-06-15 23:35:18 -05:00
|
|
|
};
|
2026-06-04 20:11:55 -05:00
|
|
|
use std::cell::RefCell;
|
2026-06-28 00:12:52 -05:00
|
|
|
use std::collections::{HashMap, HashSet};
|
2026-06-04 20:11:55 -05:00
|
|
|
use std::rc::Rc;
|
2026-06-28 00:12:52 -05:00
|
|
|
use crate::api::board::BoardRef;
|
|
|
|
|
use crate::api::object_info::ObjectInfo;
|
|
|
|
|
use crate::api::player::PlayerWithPos;
|
|
|
|
|
use crate::api::queue::ObjQueue;
|
2026-07-06 00:41:38 -05:00
|
|
|
use crate::api::registry::Registry;
|
2026-06-28 00:12:52 -05:00
|
|
|
use crate::api::state::ScriptState;
|
|
|
|
|
use crate::glyph::Glyph;
|
|
|
|
|
use crate::keys::Keyring;
|
|
|
|
|
|
|
|
|
|
/// 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);
|
2026-06-04 23:52:33 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 22:38:17 -05:00
|
|
|
/// The board queue: actions promoted from object queues, awaiting resolution.
|
2026-06-28 00:12:52 -05:00
|
|
|
pub type BoardQueue = Rc<RefCell<Vec<BoardAction>>>;
|
2026-06-06 17:36:00 -05:00
|
|
|
|
2026-06-04 20:11:55 -05:00
|
|
|
/// A compiled script plus which lifecycle hooks it defines.
|
|
|
|
|
struct CompiledScript {
|
|
|
|
|
ast: AST,
|
|
|
|
|
has_init: bool,
|
|
|
|
|
has_tick: bool,
|
2026-06-06 17:36:00 -05:00
|
|
|
has_bump: bool,
|
2026-06-21 18:27:45 -05:00
|
|
|
has_grab: bool,
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-21 18:44:42 -05:00
|
|
|
/// 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.
|
2026-06-16 14:34:42 -05:00
|
|
|
fn script_key(obj: &ObjectDef) -> Option<String> {
|
2026-06-21 18:27:45 -05:00
|
|
|
obj.script_name.clone()
|
2026-06-16 14:34:42 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 22:38:17 -05:00
|
|
|
// ── ScriptHost ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-06-04 20:11:55 -05:00
|
|
|
/// Owns the Rhai engine and per-object script state for a board.
|
|
|
|
|
pub struct ScriptHost {
|
|
|
|
|
engine: Engine,
|
|
|
|
|
scripts: HashMap<String, CompiledScript>,
|
2026-06-28 00:12:52 -05:00
|
|
|
scopes: HashMap<ObjectId, Scope<'static>>,
|
2026-06-06 17:36:00 -05:00
|
|
|
board_queue: BoardQueue,
|
|
|
|
|
errors: ErrorSink,
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ScriptHost {
|
2026-06-09 22:38:17 -05:00
|
|
|
/// Builds a host for the board behind `board_cell`: registers the full script API,
|
2026-06-06 17:36:00 -05:00
|
|
|
/// 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
|
2026-06-09 22:38:17 -05:00
|
|
|
/// unknown scripts are queued onto the error sink; no script is run here.
|
2026-06-13 01:25:58 -05:00
|
|
|
///
|
|
|
|
|
/// `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<String, String>) -> Self {
|
2026-06-06 17:36:00 -05:00
|
|
|
let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new()));
|
2026-06-28 00:12:52 -05:00
|
|
|
let errors = ErrorSink::new();
|
|
|
|
|
let mut scopes = HashMap::new();
|
2026-06-04 20:11:55 -05:00
|
|
|
|
|
|
|
|
let mut engine = Engine::new();
|
2026-06-28 00:12:52 -05:00
|
|
|
|
|
|
|
|
PlayerWithPos::register(&mut engine, errors.clone());
|
|
|
|
|
Keyring::register(&mut engine, errors.clone());
|
|
|
|
|
BoardRef::register(&mut engine, errors.clone());
|
|
|
|
|
ScriptState::register(&mut engine, errors.clone());
|
|
|
|
|
ObjectInfo::register(&mut engine, errors.clone());
|
|
|
|
|
Glyph::register(&mut engine, errors.clone());
|
|
|
|
|
ObjQueue::register(&mut engine, errors.clone());
|
2026-07-06 00:41:38 -05:00
|
|
|
Registry::register(&mut engine, errors.clone());
|
2026-06-28 00:12:52 -05:00
|
|
|
|
|
|
|
|
register_write_api(&mut engine, board_cell.clone());
|
2026-07-06 00:41:38 -05:00
|
|
|
register_global_constants(&mut engine, board_cell.clone());
|
2026-06-04 23:52:33 -05:00
|
|
|
|
2026-06-05 00:12:57 -05:00
|
|
|
let board = board_cell.borrow();
|
2026-06-04 20:11:55 -05:00
|
|
|
|
2026-06-21 18:44:42 -05:00
|
|
|
// 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`.
|
2026-06-04 20:11:55 -05:00
|
|
|
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
|
2026-06-04 23:52:33 -05:00
|
|
|
let mut failed: HashSet<String> = HashSet::new();
|
2026-06-06 20:01:07 -05:00
|
|
|
for obj in board.objects.values() {
|
2026-06-16 14:34:42 -05:00
|
|
|
let Some(key) = script_key(obj) else {
|
2026-06-15 23:35:18 -05:00
|
|
|
continue;
|
|
|
|
|
};
|
2026-06-16 14:34:42 -05:00
|
|
|
if scripts.contains_key(&key) || failed.contains(&key) {
|
2026-06-15 23:35:18 -05:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-06-16 14:34:42 -05:00
|
|
|
// 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());
|
2026-06-28 00:12:52 -05:00
|
|
|
errors.error(format!("object references unknown script '{key}'"));
|
2026-06-16 14:34:42 -05:00
|
|
|
continue;
|
2026-06-04 23:52:33 -05:00
|
|
|
}
|
2026-06-16 14:34:42 -05:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
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 {
|
2026-06-28 00:12:52 -05:00
|
|
|
has_init: defines("init", 2),
|
|
|
|
|
has_tick: defines("tick", 3),
|
|
|
|
|
has_bump: defines("bump", 3),
|
|
|
|
|
has_grab: defines("grab", 2),
|
2026-06-16 14:34:42 -05:00
|
|
|
ast,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
failed.insert(key.clone());
|
2026-06-28 00:12:52 -05:00
|
|
|
errors.error(format!("script '{key}' failed to compile: {err}"));
|
2026-06-04 23:52:33 -05:00
|
|
|
}
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 22:38:17 -05:00
|
|
|
// One runtime per object whose script compiled.
|
2026-06-06 20:01:07 -05:00
|
|
|
for (&id, obj) in board.objects.iter() {
|
2026-06-16 14:34:42 -05:00
|
|
|
let Some(key) = script_key(obj) else {
|
2026-06-15 23:35:18 -05:00
|
|
|
continue;
|
|
|
|
|
};
|
2026-06-16 14:34:42 -05:00
|
|
|
if !scripts.contains_key(&key) {
|
2026-06-15 23:35:18 -05:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-07-06 00:41:38 -05:00
|
|
|
scopes.insert(id, Scope::new());
|
2026-06-06 17:36:00 -05:00
|
|
|
}
|
2026-06-04 20:11:55 -05:00
|
|
|
|
2026-06-05 00:12:57 -05:00
|
|
|
drop(board);
|
2026-06-15 23:35:18 -05:00
|
|
|
Self {
|
|
|
|
|
engine,
|
|
|
|
|
scripts,
|
|
|
|
|
board_queue,
|
|
|
|
|
errors,
|
2026-06-28 00:12:52 -05:00
|
|
|
scopes,
|
2026-06-15 23:35:18 -05:00
|
|
|
}
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 22:38:17 -05:00
|
|
|
/// Calls `tick(dt)` on every scripted object that defines it, then drains queues.
|
2026-06-25 19:16:46 -05:00
|
|
|
pub fn run_tick(&mut self, state: ScriptState, dt: f64) {
|
2026-06-28 00:12:52 -05:00
|
|
|
self.run_hook_on_all(Hook::Tick, state, dt);
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
pub fn run_init(&mut self, state: ScriptState) {
|
|
|
|
|
self.run_hook_on_all(Hook::Init, state, 0.0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Run the given hook on every object that defines it. For hooks other than
|
|
|
|
|
/// `Tick`, dt should just be 0.0
|
|
|
|
|
fn run_hook_on_all(&mut self, hook: Hook, state: ScriptState, dt: f64) {
|
|
|
|
|
let all_ids = state.board.borrow().all_ids();
|
|
|
|
|
let arg = match hook {
|
|
|
|
|
Hook::Tick => Some(Dynamic::from(dt)),
|
|
|
|
|
_ => None,
|
2026-06-06 17:36:00 -05:00
|
|
|
};
|
2026-06-28 00:12:52 -05:00
|
|
|
|
|
|
|
|
for id in all_ids {
|
|
|
|
|
self.run_hook_on_one(hook, state.clone(), id, arg.clone(), dt)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_hook_on_one(&mut self, hook: Hook, state: ScriptState, id: ObjectId, arg: Option<Dynamic>, dt: f64) {
|
|
|
|
|
if let Some(mut info) = ObjectInfo::from_id(id, state.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()),
|
|
|
|
|
Dynamic::from(state.clone())
|
|
|
|
|
];
|
2026-06-28 00:17:08 -05:00
|
|
|
if let Some(d) = arg { args.push(d) }
|
2026-06-28 00:12:52 -05:00
|
|
|
|
|
|
|
|
// 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(self.board_queue.clone(), dt)
|
2026-06-08 22:15:44 -05:00
|
|
|
}
|
2026-06-28 00:12:52 -05:00
|
|
|
} else {
|
|
|
|
|
unreachable!("Object not found");
|
2026-06-06 17:36:00 -05:00
|
|
|
}
|
2026-06-28 00:12:52 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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, state: ScriptState, id: ObjectId, bumper: i64) {
|
|
|
|
|
self.run_hook_on_one(Hook::Bump, state, id, Some(Dynamic::from(bumper)), 0.0);
|
2026-06-09 22:38:17 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-21 18:27:45 -05:00
|
|
|
/// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the
|
|
|
|
|
/// hook, then drains its queue with `dt = 0`.
|
|
|
|
|
///
|
|
|
|
|
/// Fired when the player walks onto a grab object or a grab object is pushed
|
|
|
|
|
/// into the player (see [`GameState`](crate::game::GameState)). The hook
|
|
|
|
|
/// typically increments a player stat and removes the object via `die()`.
|
2026-06-25 19:16:46 -05:00
|
|
|
pub fn run_grab(&mut self, state: ScriptState, object_id: ObjectId) {
|
2026-06-28 00:12:52 -05:00
|
|
|
self.run_hook_on_one(Hook::Grab, state, object_id, None, 0.0);
|
2026-06-21 18:27:45 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 22:38:17 -05:00
|
|
|
/// Calls the named function on the object with [`ObjectId`] `target_id`.
|
|
|
|
|
///
|
2026-06-28 00:12:52 -05:00
|
|
|
/// What we pass depends on arity, in this order:
|
|
|
|
|
/// - If it has arity 3, we pass `[ObjectInfo, ScriptState, SendArg]`
|
|
|
|
|
/// - If it has arity 2, we pass `[ObjectInfo, ScriptState]`
|
|
|
|
|
/// - If it has arity 1, we pass the arg
|
|
|
|
|
/// - 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`
|
|
|
|
|
pub(crate) fn run_send(&mut self, state: ScriptState, id: ObjectId, fn_name: &str, arg: SendArg) {
|
|
|
|
|
if let Some(mut info) = ObjectInfo::from_id(id, state.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();
|
2026-06-09 22:38:17 -05:00
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Assemble the args
|
|
|
|
|
let mut args = vec![];
|
|
|
|
|
if arities.contains(&3) {
|
|
|
|
|
args.push(Dynamic::from(info.clone()));
|
|
|
|
|
args.push(Dynamic::from(state.clone()));
|
|
|
|
|
args.push(arg.into());
|
|
|
|
|
} else if arities.contains(&2) {
|
|
|
|
|
args.push(Dynamic::from(info.clone()));
|
|
|
|
|
args.push(Dynamic::from(state.clone()));
|
|
|
|
|
} else if arities.contains(&1) {
|
|
|
|
|
args.push(arg.into());
|
|
|
|
|
}
|
2026-06-09 22:38:17 -05:00
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
// 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(self.board_queue.clone(), 0.0)
|
2026-06-09 22:38:17 -05:00
|
|
|
}
|
2026-06-28 00:12:52 -05:00
|
|
|
} else {
|
|
|
|
|
unreachable!("Object id not found, tried to send");
|
2026-06-09 22:38:17 -05:00
|
|
|
}
|
2026-06-06 17:36:00 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Removes and returns the actions promoted onto the board queue.
|
2026-06-09 22:38:17 -05:00
|
|
|
pub(crate) fn take_board_queue(&mut self) -> Vec<BoardAction> {
|
2026-06-06 17:36:00 -05:00
|
|
|
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<LogLine> {
|
2026-06-28 00:12:52 -05:00
|
|
|
self.errors.take()
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-04 23:52:33 -05:00
|
|
|
|
2026-06-09 22:38:17 -05:00
|
|
|
// ── Write API ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
fn register_write_api(engine: &mut Engine, board: BoardRef) {
|
2026-06-04 23:52:33 -05:00
|
|
|
engine.register_type_with_name::<Direction>("Direction");
|
|
|
|
|
|
2026-06-08 22:15:44 -05:00
|
|
|
// move(dir): enqueue a Move followed by a rate-limiting Delay.
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-04 23:52:33 -05:00
|
|
|
engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| {
|
2026-06-08 22:15:44 -05:00
|
|
|
let src = source_of(&ctx);
|
2026-06-28 00:12:52 -05:00
|
|
|
if let Some(def) = b.borrow_mut().objects.get_mut(&src) {
|
|
|
|
|
def.queue.act(Action::Move(dir));
|
|
|
|
|
def.queue.delay(MOVE_COST);
|
2026-06-08 22:15:44 -05:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
|
|
|
|
engine.register_fn("delay", move |ctx: NativeCallContext, dt: Dynamic| {
|
2026-06-09 22:38:17 -05:00
|
|
|
let src = source_of(&ctx);
|
2026-06-28 00:12:52 -05:00
|
|
|
if let Some(def) = b.borrow_mut().objects.get_mut(&src)
|
|
|
|
|
&& let Ok(dt) = dt.as_float() {
|
|
|
|
|
def.queue.delay(dt);
|
2026-06-09 22:38:17 -05:00
|
|
|
}
|
|
|
|
|
});
|
2026-06-08 22:17:04 -05:00
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-08 22:15:44 -05:00
|
|
|
engine.register_fn("now", move |ctx: NativeCallContext| {
|
|
|
|
|
let src = source_of(&ctx);
|
2026-06-28 00:12:52 -05:00
|
|
|
if let Some(def) = b.borrow_mut().objects.get_mut(&src) {
|
|
|
|
|
def.queue.now()
|
2026-06-08 22:15:44 -05:00
|
|
|
}
|
2026-06-04 23:52:33 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-04 23:52:33 -05:00
|
|
|
engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: i64| {
|
2026-06-28 00:12:52 -05:00
|
|
|
emit(&b, source_of(&ctx), Action::SetTile(tile as u32));
|
2026-06-04 23:52:33 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 18:27:45 -05:00
|
|
|
// add_gems(n): change the player's gem count (negative subtracts; clamped at 0).
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-21 18:27:45 -05:00
|
|
|
engine.register_fn("add_gems", move |ctx: NativeCallContext, n: i64| {
|
2026-06-28 00:12:52 -05:00
|
|
|
emit(&b, source_of(&ctx), Action::AddGems(n));
|
2026-06-21 18:27:45 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-23 22:52:25 -05:00
|
|
|
// alter_health(dh): change the player's health by dh (clamped to [0, max_health]).
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-23 22:52:25 -05:00
|
|
|
engine.register_fn("alter_health", move |ctx: NativeCallContext, dh: i64| {
|
2026-06-28 00:12:52 -05:00
|
|
|
emit(&b, source_of(&ctx), Action::AlterHealth(dh));
|
2026-06-23 22:52:25 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-23 20:07:53 -05:00
|
|
|
// set_key(color, present): give (true) or take (false) the named key color.
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-23 20:07:53 -05:00
|
|
|
engine.register_fn(
|
|
|
|
|
"set_key",
|
|
|
|
|
move |ctx: NativeCallContext, color: ImmutableString, present: bool| {
|
2026-06-28 00:12:52 -05:00
|
|
|
emit(&b, source_of(&ctx), Action::SetKey(color.into(), present));
|
2026-06-23 20:07:53 -05:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-21 18:27:45 -05:00
|
|
|
// die(): remove the calling object from the board.
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-21 18:27:45 -05:00
|
|
|
engine.register_fn("die", move |ctx: NativeCallContext| {
|
2026-06-28 00:12:52 -05:00
|
|
|
emit(&b, source_of(&ctx), Action::Die);
|
2026-06-21 18:27:45 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-15 23:35:18 -05:00
|
|
|
engine.register_fn(
|
|
|
|
|
"log",
|
|
|
|
|
move |ctx: NativeCallContext, msg: ImmutableString| {
|
2026-06-28 00:12:52 -05:00
|
|
|
let id = source_of(&ctx);
|
|
|
|
|
emit(&b, id, Action::Log(LogLine::raw(msg.to_string())));
|
2026-06-15 23:35:18 -05:00
|
|
|
},
|
|
|
|
|
);
|
2026-06-09 22:38:17 -05:00
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-15 23:35:18 -05:00
|
|
|
engine.register_fn(
|
|
|
|
|
"say",
|
|
|
|
|
move |ctx: NativeCallContext, msg: ImmutableString| {
|
|
|
|
|
emit(
|
2026-06-28 00:12:52 -05:00
|
|
|
&b,
|
2026-06-15 23:35:18 -05:00
|
|
|
source_of(&ctx),
|
|
|
|
|
Action::Say(msg.to_string(), SAY_DURATION),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-15 23:35:18 -05:00
|
|
|
engine.register_fn(
|
|
|
|
|
"say",
|
|
|
|
|
move |ctx: NativeCallContext, msg: ImmutableString, dur: f64| {
|
2026-06-28 00:12:52 -05:00
|
|
|
emit(&b, source_of(&ctx), Action::Say(msg.to_string(), dur));
|
2026-06-15 23:35:18 -05:00
|
|
|
},
|
|
|
|
|
);
|
2026-06-09 22:38:17 -05:00
|
|
|
|
2026-06-10 01:42:33 -05:00
|
|
|
// 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].
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-10 01:42:33 -05:00
|
|
|
engine.register_fn("scroll", move |ctx: NativeCallContext, arr: Array| {
|
2026-06-15 23:35:18 -05:00
|
|
|
let lines = arr
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|item| {
|
|
|
|
|
if let Ok(s) = item.clone().into_string() {
|
|
|
|
|
Some(ScrollLine::Text(s))
|
2026-06-10 01:42:33 -05:00
|
|
|
} else {
|
2026-06-15 23:35:18 -05:00
|
|
|
let pair: Vec<Dynamic> = 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
|
|
|
|
|
}
|
2026-06-10 01:42:33 -05:00
|
|
|
}
|
2026-06-15 23:35:18 -05:00
|
|
|
})
|
|
|
|
|
.collect();
|
2026-06-28 00:12:52 -05:00
|
|
|
emit(&b, source_of(&ctx), Action::Scroll(lines));
|
2026-06-10 01:42:33 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-09 22:38:17 -05:00
|
|
|
engine.register_fn(
|
|
|
|
|
"set_tag",
|
|
|
|
|
move |ctx: NativeCallContext, target: i64, tag: ImmutableString, present: bool| {
|
2026-06-28 00:12:52 -05:00
|
|
|
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 })
|
|
|
|
|
}
|
2026-06-09 22:38:17 -05:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// set_fg(fg): change foreground color only.
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-15 23:35:18 -05:00
|
|
|
engine.register_fn(
|
|
|
|
|
"set_fg",
|
|
|
|
|
move |ctx: NativeCallContext, fg: ImmutableString| {
|
|
|
|
|
emit(
|
2026-06-28 00:12:52 -05:00
|
|
|
&b,
|
2026-06-15 23:35:18 -05:00
|
|
|
source_of(&ctx),
|
|
|
|
|
Action::SetColor {
|
|
|
|
|
fg: Some(parse_color(fg.as_str())),
|
|
|
|
|
bg: None,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-06-09 22:38:17 -05:00
|
|
|
|
|
|
|
|
// set_bg(bg): change background color only.
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-15 23:35:18 -05:00
|
|
|
engine.register_fn(
|
|
|
|
|
"set_bg",
|
|
|
|
|
move |ctx: NativeCallContext, bg: ImmutableString| {
|
|
|
|
|
emit(
|
2026-06-28 00:12:52 -05:00
|
|
|
&b,
|
2026-06-15 23:35:18 -05:00
|
|
|
source_of(&ctx),
|
|
|
|
|
Action::SetColor {
|
|
|
|
|
fg: None,
|
|
|
|
|
bg: Some(parse_color(bg.as_str())),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-06-09 22:38:17 -05:00
|
|
|
|
|
|
|
|
// set_color(fg, bg): change both colors.
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-06 18:49:45 -05:00
|
|
|
engine.register_fn(
|
2026-06-09 22:38:17 -05:00
|
|
|
"set_color",
|
|
|
|
|
move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| {
|
2026-06-15 23:35:18 -05:00
|
|
|
emit(
|
2026-06-28 00:12:52 -05:00
|
|
|
&b,
|
2026-06-15 23:35:18 -05:00
|
|
|
source_of(&ctx),
|
|
|
|
|
Action::SetColor {
|
|
|
|
|
fg: Some(parse_color(fg.as_str())),
|
|
|
|
|
bg: Some(parse_color(bg.as_str())),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-06-06 18:49:45 -05:00
|
|
|
},
|
|
|
|
|
);
|
2026-06-07 01:26:18 -05:00
|
|
|
|
2026-06-09 22:38:17 -05:00
|
|
|
// send(target, fn_name): call a named function on another object.
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-08 22:15:44 -05:00
|
|
|
engine.register_fn(
|
2026-06-09 22:38:17 -05:00
|
|
|
"send",
|
|
|
|
|
move |ctx: NativeCallContext, target: i64, name: ImmutableString| {
|
2026-06-15 23:35:18 -05:00
|
|
|
emit(
|
2026-06-28 00:12:52 -05:00
|
|
|
&b,
|
2026-06-15 23:35:18 -05:00
|
|
|
source_of(&ctx),
|
|
|
|
|
Action::Send {
|
|
|
|
|
target: target as ObjectId,
|
|
|
|
|
fn_name: name.to_string(),
|
2026-06-28 00:12:52 -05:00
|
|
|
arg: SendArg::None,
|
2026-06-15 23:35:18 -05:00
|
|
|
},
|
|
|
|
|
);
|
2026-06-08 22:15:44 -05:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-09 22:38:17 -05:00
|
|
|
// send(target, fn_name, arg): same, with an optional string or number argument.
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-07 01:26:18 -05:00
|
|
|
engine.register_fn(
|
2026-06-09 22:38:17 -05:00
|
|
|
"send",
|
|
|
|
|
move |ctx: NativeCallContext, target: i64, name: ImmutableString, arg: Dynamic| {
|
2026-06-15 23:35:18 -05:00
|
|
|
emit(
|
2026-06-28 00:12:52 -05:00
|
|
|
&b,
|
2026-06-15 23:35:18 -05:00
|
|
|
source_of(&ctx),
|
|
|
|
|
Action::Send {
|
|
|
|
|
target: target as ObjectId,
|
|
|
|
|
fn_name: name.to_string(),
|
2026-06-28 00:12:52 -05:00
|
|
|
arg: arg.into(),
|
2026-06-15 23:35:18 -05:00
|
|
|
},
|
|
|
|
|
);
|
2026-06-07 01:26:18 -05:00
|
|
|
},
|
|
|
|
|
);
|
2026-06-13 17:58:04 -05:00
|
|
|
|
|
|
|
|
// 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.
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-13 17:58:04 -05:00
|
|
|
engine.register_fn("teleport", move |ctx: NativeCallContext, x: i64, y: i64| {
|
2026-06-28 00:12:52 -05:00
|
|
|
emit(&b, source_of(&ctx), Action::Teleport { x, y });
|
2026-06-13 17:58:04 -05:00
|
|
|
});
|
2026-06-21 01:32:47 -05:00
|
|
|
|
|
|
|
|
// 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).
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-21 01:32:47 -05:00
|
|
|
engine.register_fn(
|
|
|
|
|
"push",
|
|
|
|
|
move |ctx: NativeCallContext, x: i64, y: i64, dir: Direction| {
|
2026-06-28 00:12:52 -05:00
|
|
|
emit(&b, source_of(&ctx), Action::Push { x, y, dir, });
|
|
|
|
|
}
|
2026-06-21 01:32:47 -05:00
|
|
|
);
|
|
|
|
|
|
2026-06-21 22:04:10 -05:00
|
|
|
// 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).
|
2026-06-28 00:12:52 -05:00
|
|
|
let b = board.clone();
|
2026-06-21 22:04:10 -05:00
|
|
|
engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| {
|
2026-06-21 01:32:47 -05:00
|
|
|
let src = source_of(&ctx);
|
2026-06-21 22:04:10 -05:00
|
|
|
match read_coord_array(&arr) {
|
2026-06-28 00:12:52 -05:00
|
|
|
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())))
|
2026-06-21 01:32:47 -05:00
|
|
|
}
|
|
|
|
|
});
|
2026-06-06 17:36:00 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-21 22:04:10 -05:00
|
|
|
/// 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], ...]`
|
2026-06-28 00:12:52 -05:00
|
|
|
fn read_coord_array(arr: &Array) -> Result<Vec<(i64, i64)>, ()> {
|
|
|
|
|
let mut pairs = Vec::new();
|
2026-06-21 22:04:10 -05:00
|
|
|
for elem in arr {
|
|
|
|
|
let coords: Option<Vec<i64>> = elem.read_lock::<Array>().and_then(|inner| {
|
|
|
|
|
inner.iter().map(|v| v.as_int().ok()).collect()
|
|
|
|
|
});
|
|
|
|
|
if let Some(coords) = coords && coords.len() == 2 {
|
2026-06-28 00:12:52 -05:00
|
|
|
pairs.push((coords[0], coords[1]))
|
2026-06-21 22:04:10 -05:00
|
|
|
} else {
|
|
|
|
|
return Err(());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(pairs)
|
|
|
|
|
}
|
2026-06-09 22:38:17 -05:00
|
|
|
|
2026-06-13 15:33:04 -05:00
|
|
|
// ── 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.)
|
2026-07-06 00:41:38 -05:00
|
|
|
fn register_global_constants(engine: &mut Engine, board: BoardRef) {
|
2026-06-13 15:33:04 -05:00
|
|
|
let mut m = Module::new();
|
2026-07-06 00:41:38 -05:00
|
|
|
m.set_var("Board", board);
|
2026-06-13 15:33:04 -05:00
|
|
|
m.set_var("North", Direction::North);
|
|
|
|
|
m.set_var("South", Direction::South);
|
2026-06-15 23:35:18 -05:00
|
|
|
m.set_var("East", Direction::East);
|
|
|
|
|
m.set_var("West", Direction::West);
|
2026-06-20 16:21:04 -05:00
|
|
|
// 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));
|
|
|
|
|
}
|
2026-06-13 15:33:04 -05:00
|
|
|
engine.register_global_module(m.into());
|
|
|
|
|
}
|
2026-06-09 22:38:17 -05:00
|
|
|
|
2026-06-06 17:36:00 -05:00
|
|
|
/// Appends `action` to the output queue of the object identified by `source`.
|
2026-06-28 00:12:52 -05:00
|
|
|
fn emit(board: &BoardRef, source: ObjectId, action: Action) {
|
|
|
|
|
if let Some(def) = board.borrow_mut().objects.get_mut(&source) {
|
|
|
|
|
def.queue.act(action);
|
2026-06-06 17:36:00 -05:00
|
|
|
}
|
2026-06-04 23:52:33 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 22:38:17 -05:00
|
|
|
/// Reads the issuing object's [`ObjectId`] from the call tag.
|
2026-06-06 20:01:07 -05:00
|
|
|
fn source_of(ctx: &NativeCallContext) -> ObjectId {
|
2026-06-04 23:52:33 -05:00
|
|
|
ctx.tag()
|
|
|
|
|
.and_then(|t| t.as_int().ok())
|
2026-06-06 20:01:07 -05:00
|
|
|
.and_then(|n| ObjectId::try_from(n).ok())
|
2026-06-04 23:52:33 -05:00
|
|
|
.unwrap_or(0)
|
|
|
|
|
}
|