Files
kiln/kiln-core/src/script.rs
T
2026-06-08 22:17:04 -05:00

585 lines
24 KiB
Rust

//! 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`].
//!
//! ## Actions, queues, and rate limiting
//!
//! Scripts don't mutate the world directly. Each write host function (`move`,
//! `set_tile`, `log`, `say`) appends an [`Action`] to the issuing object's **output
//! queue** (a per-object FIFO).
//!
//! Timing is encoded **inline** in the queue as [`Action::Delay`] entries rather than
//! as an external per-object timer. `move(dir)` appends both a `Move` and a
//! `Delay(MOVE_COST)` so the object pauses before its next action. Adjacent delays are
//! merged at push time so the queue never contains two consecutive `Delay`s.
//!
//! Each tick, [`ScriptHost::drain`] advances the queue for each object:
//! it promotes non-delay actions onto the shared **board queue** until it hits a live
//! `Delay`, which it decrements (and removes when expired). `dt` is consumed on the
//! first `Delay` encountered so multiple delays in one tick are not double-decremented.
//!
//! The `now()` host function pops the most recently enqueued action to the front of
//! the queue, letting scripts prioritize a zero-cost action (e.g. `say`) over a
//! pending delay.
//!
//! [`crate::game::GameState`] drains the board queue ([`take_board_queue`]) and applies
//! each action *after* the script batch, preserving the borrow discipline (scripts only
//! ever read the board during execution). Which object issued an action is read from the
//! per-call **tag** (set to the object index in [`ScriptHost::run`]).
//!
//! [`take_board_queue`]: ScriptHost::take_board_queue
//! [`GameState`]: crate::game::GameState
use crate::board::Board;
use crate::log::LogLine;
use rhai::{CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope, AST};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc;
use crate::utils::{Action, Direction, ObjectId, MOVE_COST};
/// An action promoted from an object's output queue onto the board queue, tagged
/// with the object that issued it.
pub struct BoardAction {
/// The stable [`ObjectId`] of the object that issued the action.
pub source: ObjectId,
/// The action to apply.
pub action: Action,
}
/// A read-only handle to the world, exposed to scripts as `Board`. Holds a shared
/// reference to the [`Board`]; only getters are registered, so it is
/// read-only by construction.
type BoardRef = Rc<RefCell<Board>>;
/// A single object's output queue, shared between the host (which pumps it between
/// script calls) and the script (which reads/mutates it through the `Queue` object).
type ObjQueue = Rc<RefCell<VecDeque<Action>>>;
/// The board queue: actions promoted from object queues, awaiting resolution. Shared
/// so the `blocked` host function can scan pending moves during script execution.
type BoardQueue = Rc<RefCell<Vec<BoardAction>>>;
/// Maps an [`ObjectId`] to its output queue, so the global write host functions can
/// route an emitted action to the right object via the per-call tag.
type QueueMap = Rc<RefCell<HashMap<ObjectId, ObjQueue>>>;
/// Channel for engine/compile/runtime errors, surfaced straight to the game log
/// (errors never go through the action queues).
type ErrorSink = Rc<RefCell<Vec<LogLine>>>;
/// 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,
/// Whether the script defines `fn bump(id)` (one parameter).
has_bump: bool,
}
/// The runtime state of one scripted object: which script it runs, its persistent
/// Rhai scope, and its pending action queue.
struct ObjectRuntime {
/// The stable [`ObjectId`] of this object; passed to scripts as the per-call
/// tag so host functions know which object issued an action.
object_id: ObjectId,
/// Key into [`ScriptHost::scripts`] naming this object's compiled script.
script_name: String,
/// Per-object scope (holds the `Board`/`Queue` views + direction constants, and
/// can hold object-local state in the future). Persists across calls.
scope: Scope<'static>,
/// This object's output queue (shared with its scope's `Queue` object).
/// May contain [`Action::Delay`] entries that pace the release of other actions.
output_queue: ObjQueue,
}
/// Owns the Rhai engine and per-object script state for a board.
pub struct ScriptHost {
/// The Rhai engine, with the read getters and write host functions registered.
engine: Engine,
/// Compiled scripts, keyed by script name; compiled once per referenced name.
scripts: HashMap<String, CompiledScript>,
/// One entry per object that has a successfully compiled script.
objects: Vec<ObjectRuntime>,
/// Actions promoted from object queues, drained by [`Self::take_board_queue`].
board_queue: BoardQueue,
/// Errors collected during compile/run, drained by [`Self::take_errors`].
errors: ErrorSink,
}
impl ScriptHost {
/// Builds a host for the board behind `board_cell`: registers the read/write 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 (retrievable via
/// [`Self::take_errors`]); no script is run here.
pub fn new(board_cell: &BoardRef) -> 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);
register_write_api(&mut engine, &queues);
register_queue_api(&mut engine);
let board = board_cell.borrow();
// Compile each referenced script once; attribute failures to the first
// object that references the script.
let mut scripts: HashMap<String, CompiledScript> = HashMap::new();
let mut failed: HashSet<String> = HashSet::new();
for obj in board.objects.values() {
let Some(name) = obj.script_name.as_ref() else {
continue;
};
if scripts.contains_key(name) || failed.contains(name) {
continue;
}
match board.scripts.get(name) {
Some(src) => match engine.compile(src) {
Ok(ast) => {
// Detect the optional hooks by name and arity.
let defines = |n: &str, params: usize| {
ast.iter_functions()
.any(|f| f.name == n && f.params.len() == params)
};
let compiled = CompiledScript {
has_init: defines("init", 0),
has_tick: defines("tick", 1),
has_bump: defines("bump", 1),
ast,
};
scripts.insert(name.clone(), compiled);
}
Err(err) => {
failed.insert(name.clone());
errors.borrow_mut().push(LogLine::raw(format!(
"script '{name}' failed to compile: {err}"
)));
}
},
None => {
failed.insert(name.clone());
errors.borrow_mut().push(LogLine::raw(format!(
"object references unknown script '{name}'"
)));
}
}
}
// One runtime (independent scope + output queue) per object whose script
// compiled. The queue is registered in `queues` so host functions can find
// it by the object's index, and shared into the scope as the `Queue` object.
let mut objects = Vec::new();
for (&id, obj) in board.objects.iter() {
let Some(name) = obj.script_name.as_ref() else {
continue;
};
if !scripts.contains_key(name) {
continue;
}
let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new()));
queues.borrow_mut().insert(id, queue.clone());
objects.push(ObjectRuntime {
object_id: id,
script_name: name.clone(),
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
/// with `dt = 0` (no time advance — just flush any zero-cost front actions).
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 each
/// queue, advancing inline [`Action::Delay`]s by `dt`.
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.
///
/// `bumper` is the id of the object that moved into it, or `-1` for the player.
/// After the hook, drains the object's queue with `dt = 0` so any zero-cost actions
/// the hook enqueued (e.g. `say(); now()`) take effect in the same frame.
pub fn run_bump(&mut self, object_id: ObjectId, bumper: i64) {
// Find the index first so we can drain after the split-borrow block.
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_name) 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::raw(format!(
"script '{}' bump error: {err}", obj.script_name
)));
}
}
// dt=0: flush zero-cost front actions without advancing any delays.
self.drain(i, 0.0);
}
/// Removes and returns the actions promoted onto the board queue.
pub fn take_board_queue(&mut self) -> Vec<BoardAction> {
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> {
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`.
///
/// Non-delay actions are promoted immediately. When a `Delay` is encountered it is
/// decremented by the remaining `dt` (which is then consumed so subsequent delays
/// in the same call are not also decremented). An expired delay is removed and
/// draining continues; a live delay stops the loop for this call.
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();
// Peek at the front to decide what to do without holding the borrow.
let is_delay = matches!(queue.borrow().front(), Some(Action::Delay(_)));
if is_delay {
// Decrement the delay; pop it only if it expires.
let expired = {
let mut q = queue.borrow_mut();
let Some(Action::Delay(r)) = q.front_mut() else { break };
*r -= dt;
dt = 0.0; // consume so later delays in this call aren't also decremented
*r <= 0.0
};
if expired {
queue.borrow_mut().pop_front();
// Continue — drain actions after the expired delay.
} else {
break; // Live delay: stop draining.
}
} 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`: for each object, call the hook if its script
/// defines it (tagging the call with the object's id so host functions know the
/// source), then drain that object's queue so actions reach the board queue before
/// the next object's `blocked` check runs.
fn run<A: FuncArgs + Copy>(
&mut self,
hook: &str,
defined: fn(&CompiledScript) -> bool,
args: A,
drain_dt: f64,
) {
for i in 0..self.objects.len() {
// Scope the split borrow so it ends before the drain reborrow below.
{
let Self { engine, scripts, objects, errors, .. } = self;
let obj = &mut objects[i];
if let Some(compiled) = scripts.get(&obj.script_name)
&& 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::raw(format!(
"script '{}' {hook} error: {err}", obj.script_name
)));
}
}
}
self.drain(i, drain_dt);
}
}
}
/// Registers the read-only `Board` API (getters plus the `blocked` query).
///
/// The getters operate on the `Board` value in scope; `blocked` instead captures a
/// clone of the world and the board queue, so it can resolve the caller's position and
/// scan pending moves.
fn register_read_api(engine: &mut Engine, board: &BoardRef, board_queue: &BoardQueue) {
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);
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)
});
// has_tag(s) -> bool: does the calling object have this tag?
let b = board.clone();
engine.register_fn("has_tag", move |ctx: NativeCallContext, tag: ImmutableString| {
let src = source_of(&ctx);
b.borrow().objects.get(&src).is_some_and(|o| o.tags.contains(tag.as_str()))
});
// get_tags() -> Array of strings: the calling object's current tag set.
let b = board.clone();
engine.register_fn("get_tags", move |ctx: NativeCallContext| -> rhai::Array {
let src = source_of(&ctx);
b.borrow()
.objects
.get(&src)
.map(|o| o.tags.iter().map(|t| rhai::Dynamic::from(t.clone())).collect())
.unwrap_or_default()
});
// objects_with_tag(s) -> Array of i64: ids of all objects carrying the tag.
let b = board.clone();
engine.register_fn(
"objects_with_tag",
move |_ctx: NativeCallContext, tag: ImmutableString| -> rhai::Array {
b.borrow()
.objects
.iter()
.filter(|(_, o)| o.tags.contains(tag.as_str()))
.map(|(&id, _)| rhai::Dynamic::from(id as i64))
.collect()
},
);
// my_name() -> String: the calling object's name, or "" if unnamed.
let b = board.clone();
engine.register_fn("my_name", move |ctx: NativeCallContext| -> String {
let src = source_of(&ctx);
b.borrow()
.objects
.get(&src)
.and_then(|o| o.name.as_deref())
.unwrap_or("")
.to_string()
});
// object_id_for_name(s) -> i64: id of the object with the given name, or 0 if none.
let b = board.clone();
engine.register_fn(
"object_id_for_name",
move |_ctx: NativeCallContext, name: ImmutableString| -> i64 {
b.borrow()
.objects
.iter()
.find(|(_, o)| o.name.as_deref() == Some(name.as_str()))
.map(|(&id, _)| id as i64)
.unwrap_or(0)
},
);
}
/// Whether the object at `source` would bump something by moving in `dir`.
///
/// Returns `true` if the target cell is off the board, already holds a solid, or is
/// the destination of any pending move on the board queue. The caller's own move is
/// never on the queue yet (its pump runs after its script), so it can't block itself.
///
/// Pending moves are matched by their *immediate* target cell only; a queued move that
/// would push a chain of solids into the cell is not accounted for (an approximation).
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;
}
// Any pending move whose immediate target is this cell would put a solid here.
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
})
}
/// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log`/
/// `say`/`now` host functions.
///
/// `move(dir)` pushes both a `Move` and a `Delay(MOVE_COST)` so the object's queue
/// paces itself automatically. `now()` promotes the most recently enqueued action to
/// the front of the queue, letting a zero-cost action bypass a pending delay.
fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
engine.register_type_with_name::<Direction>("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 into the queue, merged with any trailing Delay.
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));
}
});
// now(): pop the back of the queue and push it to the front.
// Use after a zero-cost action (say, log) to bypass any pending Delay.
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()));
},
);
// set_tag(target_id, tag, present): add (true) or remove (false) a tag on any object.
// Use MY_ID as target_id to mutate the calling object's own tags.
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,
},
);
},
);
}
/// Registers the `Queue` object: a handle to an object's output queue with `length()`
/// and `clear()`. Safe to mutate from a script because the host only touches these
/// queues between script calls (during [`ScriptHost::pump`]).
fn register_queue_api(engine: &mut Engine) {
engine.register_type_with_name::<ObjQueue>("Queue");
engine.register_fn("length", |q: &mut ObjQueue| q.borrow().len() as i64);
engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear());
}
/// 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 (set in [`ScriptHost::run`]).
///
/// Falls back to `0` (never a valid id, since ids start at 1) if the tag is missing
/// or out of range, which makes [`emit`] a harmless no-op for an unknown source.
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)
}
/// Builds a fresh per-object scope, seeded with the read-only `Board` view, this
/// object's `Queue`, the four direction constants, and `MY_ID` (the object's own
/// stable [`ObjectId`] as `i64`, for use with `set_tag` and similar calls).
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("North", Direction::North);
scope.push_constant("South", Direction::South);
scope.push_constant("East", Direction::East);
scope.push_constant("West", Direction::West);
scope.push_constant("MY_ID", object_id as i64);
scope
}