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
|
|
|
|
|
//! 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`]).
|
|
|
|
|
//!
|
2026-06-04 23:52:33 -05:00
|
|
|
//! ## Reads vs. writes
|
|
|
|
|
//!
|
2026-06-05 01:09:50 -05:00
|
|
|
//! Scripts **read** the world directly through a read-only `Rc<RefCell<Board>>` (type aliased
|
|
|
|
|
//! as [`BoardRef`], registering getters only) pushed into each scope as `Board`, e.g.
|
|
|
|
|
//! `Board.player_x`. Functions borrow it briefly per getter.
|
2026-06-04 23:52:33 -05:00
|
|
|
//!
|
|
|
|
|
//! 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`]),
|
2026-06-05 01:09:50 -05:00
|
|
|
//! so scripts write `move(North)` without naming themselves.
|
2026-06-04 23:52:33 -05:00
|
|
|
//!
|
|
|
|
|
//! [`GameState`]: crate::game::GameState
|
2026-06-04 20:11:55 -05:00
|
|
|
|
2026-06-05 00:12:57 -05:00
|
|
|
use crate::game::Board;
|
2026-06-04 20:11:55 -05:00
|
|
|
use crate::log::LogLine;
|
2026-06-04 23:52:33 -05:00
|
|
|
use rhai::{AST, CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope};
|
2026-06-04 20:11:55 -05:00
|
|
|
use std::cell::RefCell;
|
2026-06-04 23:52:33 -05:00
|
|
|
use std::collections::{HashMap, HashSet};
|
2026-06-04 20:11:55 -05:00
|
|
|
use std::rc::Rc;
|
|
|
|
|
|
2026-06-04 23:52:33 -05:00
|
|
|
/// Shared queue the write host functions push into; drained by [`ScriptHost::take_commands`].
|
|
|
|
|
type CommandQueue = Rc<RefCell<Vec<Command>>>;
|
|
|
|
|
|
|
|
|
|
/// 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<ObjectId, ObjectDef>`). 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<Direction> 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),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 01:09:50 -05:00
|
|
|
/// A read-only handle to the world, exposed to scripts as `Board`. Holds a shared
|
2026-06-05 00:12:57 -05:00
|
|
|
/// reference to the [`Board`]; only getters are registered, so it is
|
2026-06-04 23:52:33 -05:00
|
|
|
/// read-only by construction.
|
2026-06-05 01:09:50 -05:00
|
|
|
type BoardRef = Rc<RefCell<Board>>;
|
2026-06-04 20:11:55 -05:00
|
|
|
|
|
|
|
|
/// 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 {
|
2026-06-04 23:52:33 -05:00
|
|
|
/// 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.
|
2026-06-04 20:11:55 -05:00
|
|
|
object_index: usize,
|
|
|
|
|
/// Key into [`ScriptHost::scripts`] naming this object's compiled script.
|
|
|
|
|
script_name: String,
|
2026-06-04 23:52:33 -05:00
|
|
|
/// Per-object scope (holds the `board` view + direction constants, and can
|
|
|
|
|
/// hold object-local state in the future). Persists across calls.
|
2026-06-04 20:11:55 -05:00
|
|
|
scope: Scope<'static>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Owns the Rhai engine and per-object script state for a board.
|
|
|
|
|
pub struct ScriptHost {
|
2026-06-04 23:52:33 -05:00
|
|
|
/// The Rhai engine, with the read getters and write host functions registered.
|
2026-06-04 20:11:55 -05:00
|
|
|
engine: Engine,
|
|
|
|
|
/// Compiled scripts, keyed by script name; compiled once per referenced name.
|
|
|
|
|
scripts: HashMap<String, CompiledScript>,
|
|
|
|
|
/// One entry per object that has a successfully compiled script.
|
|
|
|
|
objects: Vec<ObjectRuntime>,
|
2026-06-04 23:52:33 -05:00
|
|
|
/// Queue the write host functions push into; drained by [`Self::take_commands`].
|
|
|
|
|
commands: CommandQueue,
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ScriptHost {
|
2026-06-04 23:52:33 -05:00
|
|
|
/// 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.
|
2026-06-05 01:09:50 -05:00
|
|
|
pub fn new(board_cell: &BoardRef) -> Self {
|
2026-06-04 23:52:33 -05:00
|
|
|
let commands: CommandQueue = Rc::new(RefCell::new(Vec::new()));
|
2026-06-04 20:11:55 -05:00
|
|
|
|
|
|
|
|
let mut engine = Engine::new();
|
2026-06-04 23:52:33 -05:00
|
|
|
register_read_api(&mut engine);
|
|
|
|
|
register_write_api(&mut engine, &commands);
|
|
|
|
|
|
2026-06-05 00:12:57 -05:00
|
|
|
let board = board_cell.borrow();
|
2026-06-04 20:11:55 -05:00
|
|
|
|
2026-06-04 23:52:33 -05:00
|
|
|
// Compile each referenced script once; attribute failures to the first
|
|
|
|
|
// object that references the 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();
|
|
|
|
|
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) {
|
2026-06-04 20:11:55 -05:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-04 23:52:33 -05:00
|
|
|
Err(err) => {
|
|
|
|
|
failed.insert(name.clone());
|
|
|
|
|
commands.borrow_mut().push(Command {
|
|
|
|
|
source: i,
|
|
|
|
|
kind: GameCommand::Error(format!(
|
|
|
|
|
"script '{name}' failed to compile: {err}"
|
|
|
|
|
)),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-04 20:11:55 -05:00
|
|
|
},
|
2026-06-04 23:52:33 -05:00
|
|
|
None => {
|
|
|
|
|
failed.insert(name.clone());
|
|
|
|
|
commands.borrow_mut().push(Command {
|
|
|
|
|
source: i,
|
|
|
|
|
kind: GameCommand::Error(format!(
|
|
|
|
|
"object references unknown script '{name}'"
|
|
|
|
|
)),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// One runtime (independent scope) per object whose script compiled.
|
|
|
|
|
let objects = board
|
|
|
|
|
.objects
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
2026-06-04 23:52:33 -05:00
|
|
|
.filter_map(|(i, obj)| {
|
|
|
|
|
let name = obj.script_name.as_ref()?;
|
2026-06-04 20:11:55 -05:00
|
|
|
scripts.contains_key(name).then(|| ObjectRuntime {
|
|
|
|
|
object_index: i,
|
|
|
|
|
script_name: name.clone(),
|
2026-06-05 00:12:57 -05:00
|
|
|
scope: new_object_scope(board_cell),
|
2026-06-04 20:11:55 -05:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
2026-06-05 00:12:57 -05:00
|
|
|
drop(board);
|
2026-06-04 23:52:33 -05:00
|
|
|
|
2026-06-04 20:11:55 -05:00
|
|
|
Self {
|
|
|
|
|
engine,
|
|
|
|
|
scripts,
|
|
|
|
|
objects,
|
2026-06-04 23:52:33 -05:00
|
|
|
commands,
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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,));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 23:52:33 -05:00
|
|
|
/// Removes and returns the commands queued by scripts since the last drain.
|
|
|
|
|
pub fn take_commands(&mut self) -> Vec<Command> {
|
|
|
|
|
std::mem::take(&mut self.commands.borrow_mut())
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Shared driver for the lifecycle hooks: for each object whose script
|
2026-06-04 23:52:33 -05:00
|
|
|
/// `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.
|
2026-06-04 20:11:55 -05:00
|
|
|
fn run<A: FuncArgs + Copy>(
|
|
|
|
|
&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,
|
2026-06-04 23:52:33 -05:00
|
|
|
commands,
|
2026-06-04 20:11:55 -05:00
|
|
|
} = self;
|
|
|
|
|
for obj in objects.iter_mut() {
|
|
|
|
|
let Some(compiled) = scripts.get(&obj.script_name) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if !defined(compiled) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-06-04 23:52:33 -05:00
|
|
|
// 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
|
|
|
|
|
)),
|
|
|
|
|
});
|
2026-06-04 20:11:55 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-04 23:52:33 -05:00
|
|
|
|
2026-06-05 01:09:50 -05:00
|
|
|
/// Registers the read-only `Board` API: a [`BoardRef`] type with getters that
|
2026-06-04 23:52:33 -05:00
|
|
|
/// borrow the shared state briefly.
|
|
|
|
|
fn register_read_api(engine: &mut Engine) {
|
2026-06-05 01:09:50 -05:00
|
|
|
engine.register_type_with_name::<BoardRef>("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);
|
2026-06-04 23:52:33 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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>("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`).
|
2026-06-05 01:09:50 -05:00
|
|
|
fn new_object_scope(board: &BoardRef) -> Scope<'static> {
|
2026-06-04 23:52:33 -05:00
|
|
|
let mut scope = Scope::new();
|
2026-06-05 01:09:50 -05:00
|
|
|
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);
|
2026-06-04 23:52:33 -05:00
|
|
|
scope
|
|
|
|
|
}
|