Files
kiln/kiln-core/src/script.rs
T

339 lines
13 KiB
Rust
Raw Normal View History

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
}