Big API refactor

This commit is contained in:
2026-06-28 00:12:52 -05:00
parent db9a5d37b6
commit 9de31d933b
31 changed files with 1036 additions and 1029 deletions
+96
View File
@@ -0,0 +1,96 @@
//! ## Board read API (`state.board.*`)
//!
//! - `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
//! - `board.width` - Board width
//! - `board.height` - Board height
//!
//! ## Cell queries
//!
//! - `board.can_push(x, y, dir) -> bool` — is the pushable chain at `(x, y)` shovable in `dir`
//! - `board.passable(x, y) -> bool` — is `(x, y)` on-board and free of any solid (a hole)
//! cell (one empty, or a grab thing and the player)
use std::cell::RefCell;
use std::rc::Rc;
use rhai::{Dynamic, Engine, ImmutableString};
use crate::{Board, Direction};
use crate::api::object_info::ObjectInfo;
use crate::script::Registerable;
use crate::utils::{ErrorSink, ObjectId};
/// A read-only handle to the world, exposed to scripts as `Board`.
pub type BoardRef = Rc<RefCell<Board>>;
impl Registerable for BoardRef {
fn register(engine: &mut Engine, error_sink: ErrorSink) {
engine.register_type_with_name::<BoardRef>("Board");
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
// can_push(x, y, dir) — true if (x, y) holds a pushable whose chain can be
// shoved one step in dir (the read-only half of push()). False off-board.
engine.register_fn("can_push", move |board: BoardRef, x: i64, y: i64, dir: Direction| -> bool {
let board = board.borrow();
board.in_bounds((x, y)) && board.can_push(x as usize, y as usize, dir)
});
// passable(x, y) — true if (x, y) is on-board and holds no solid (an empty cell
// a mover could enter). Off-board is not passable. Lets a script distinguish a
// hole from a blocked solid (which can_shift/can_push alone cannot).
engine.register_fn("passable", move |board: BoardRef, x: i64, y: i64| -> bool {
let board = board.borrow();
board.in_bounds((x, y)) && board.is_passable(x as usize, y as usize)
});
// Board.get(id) -> ObjectInfo | () (unknown id logs error)
engine.register_fn("get", move |board: &mut BoardRef, id: i64| -> Dynamic {
if id <= 0 {
error_sink.error(format!("Board.get: invalid id {id}"));
return Dynamic::UNIT;
}
if let Some(obj) = ObjectInfo::from_id(id as ObjectId, board.clone()) {
Dynamic::from(obj)
} else {
error_sink.error(format!("Board.get: no object with id {id}"));
Dynamic::UNIT
}
});
// Board.named(name) -> ObjectInfo | ()
engine.register_fn("named", move |board_ref: &mut BoardRef, name: ImmutableString| -> Dynamic {
let board = board_ref.borrow();
board
.objects
.iter()
.find_map(|(_id, def)| {
if def.name.as_deref() == Some(name.as_str()) {
Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone())))
} else {
None
}
})
.unwrap_or(Dynamic::UNIT)
},
);
// Board.tagged(tag) -> Array[ObjectInfo]
engine.register_fn("tagged", move |board_ref: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
let board = board_ref.borrow();
board
.objects
.iter()
.filter_map(|(_id, def)| {
if def.tags.contains(tag.as_str()) {
Some(Dynamic::from(ObjectInfo::from_def(def, board_ref.clone())))
} else {
None
}
})
.collect()
},
);
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod state;
pub mod player;
pub mod board;
pub mod object_info;
pub mod queue;
+137
View File
@@ -0,0 +1,137 @@
//! ## Object Rhai API
//!
//! ### Getters
//!
//! - x, y -> Board location of object
//! - id -> The object's board-unique id
//! - name -> The object's name, or ()
//! - waiting -> bool for whether or not the front of this object's queue is a delay action
//! - queue -> the action queue for this object
//! - tags -> Array of tags for this object
//! - glyph -> The object's current glyph
//!
//! ### Functions
//!
//! - has_tag(tag) -> Whether the object has this tag
//! - delay(secs) -> Queue a delay action
//! - can_push(dir) -> Whether a push in this direction would succeed
//! - blocked(dir) -> Whether a solid neighbors me in this direction
//!
//! Note on blocking: can_push and blocked only take into account the contents of the board
//! at the start of the call; if another thing moves before your actions are flushed to the
//! gamestate, a move might still fail. This is a TODO.
use rhai::{Dynamic, Engine};
use crate::api::board::BoardRef;
use crate::api::queue::ObjQueue;
use crate::Direction;
use crate::object_def::ObjectDef;
use crate::script::{BoardQueue, Registerable};
use crate::utils::{ErrorSink, ObjectId};
/// 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.
/// It also contains things like script_name used by ScriptHost, since we can't keep a live
/// borrow of the board while doing anything: we create one of these loose and then
/// it borrows the board only for the duration of what it needs.
#[derive(Clone)]
pub struct ObjectInfo {
pub id: ObjectId,
pub x: i64,
pub y: i64,
pub board: BoardRef,
pub script_name: Option<String>,
pub queue: ObjQueue
}
impl ObjectInfo {
pub fn from_id(id: ObjectId, board: BoardRef) -> Option<ObjectInfo> {
let b = board.borrow();
let obj = b.objects.get(&id)?;
Some(ObjectInfo {
id,
x: obj.x as i64,
y: obj.y as i64,
board: board.clone(),
script_name: obj.script_name.clone(),
queue: obj.queue.clone()
})
}
pub fn from_def(obj: &ObjectDef, board: BoardRef) -> ObjectInfo {
Self {
id: obj.id,
x: obj.x as i64,
y: obj.y as i64,
board: board.clone(),
script_name: obj.script_name.clone(),
queue: obj.queue.clone()
}
}
pub fn drain(&mut self, target: BoardQueue, dt: f64) {
let mut b = self.board.borrow_mut();
if let Some(def) = b.objects.get_mut(&self.id) {
def.queue.drain(self.id, target, dt)
}
}
}
impl Registerable for ObjectInfo {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<ObjectInfo>("ObjectInfo")
.register_get("x", |obj: &mut ObjectInfo| obj.x)
.register_get("y", |obj: &mut ObjectInfo| obj.y)
.register_get("id", |obj: &mut ObjectInfo| obj.id as i64)
.register_get("waiting", |obj: &mut ObjectInfo| obj.queue.waiting())
.register_get("queue", |obj: &mut ObjectInfo| obj.queue.clone());
engine.register_get("name", |o: &mut ObjectInfo| {
let board = o.board.borrow();
let obj = board.objects.get(&o.id);
if let Some(ObjectDef { name: Some(name), ..}) = obj {
Dynamic::from(name.clone())
} else {
Dynamic::UNIT
}
});
engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array {
let board = o.board.borrow();
let obj = board.objects.get(&o.id);
if let Some(ObjectDef { tags, .. }) = obj {
tags.iter().map(|t| Dynamic::from(t.clone())).collect()
} else {
rhai::Array::new()
}
});
engine.register_get("glyph", |o: &mut ObjectInfo| {
let board = o.board.borrow();
let obj = board.objects.get(&o.id).unwrap();
obj.glyph
});
engine.register_fn("has_tag", |o: &mut ObjectInfo, t: String| {
if let Some(ObjectDef { tags, .. }) = o.board.borrow().objects.get(&o.id) {
tags.contains(&t)
} else {
false
}
});
engine.register_fn("delay", |o: &mut ObjectInfo, dt: f64| o.queue.delay(dt));
engine.register_fn("can_push", move |o: &mut ObjectInfo, dir: Direction| {
let board = o.board.borrow();
board.in_bounds((o.x, o.y)) && board.can_push(o.x as usize, o.y as usize, dir)
});
engine.register_fn("blocked", move |o: &mut ObjectInfo, dir: Direction| -> bool {
let board = o.board.borrow();
let tx = o.x + dir.dx();
let ty = o.y + dir.dy();
board.in_bounds((o.x, o.y)) && !board.is_passable(tx as usize, ty as usize)
});
}
}
+21
View File
@@ -0,0 +1,21 @@
use rhai::Engine;
use crate::player::Player;
use crate::script::Registerable;
use crate::utils::{ErrorSink, PlayerPos};
/// GameState stores player state but Board stores its position, and we want one
/// object to register with Rhai
#[derive(Copy, Clone)]
pub struct PlayerWithPos(pub Player, pub PlayerPos);
impl Registerable for PlayerWithPos {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<PlayerWithPos>("Player")
.register_get("gems", |player: &mut PlayerWithPos| player.0.gems)
.register_get("health", |player: &mut PlayerWithPos| player.0.health)
.register_get("max_health", |player: &mut PlayerWithPos| player.0.max_health)
.register_get("keys", |player: &mut PlayerWithPos| player.0.keys)
.register_get("x", |player: &mut PlayerWithPos| player.1.x)
.register_get("y", |player: &mut PlayerWithPos| player.1.y);
}
}
+118
View File
@@ -0,0 +1,118 @@
//! ## Queue API
//!
//! `queue.length`, `queue.clear()`, `queue.peek()`, `queue.pop()`, `queue.delay()`
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use rhai::{Dynamic, Engine};
use crate::action::{action_to_map, Action, BoardAction};
use crate::script::{BoardQueue, Registerable};
use crate::utils::{ErrorSink, ObjectId};
/// A single object's output queue.
#[derive(Clone)]
pub struct ObjQueue(Rc<RefCell<VecDeque<Action>>>);
impl ObjQueue {
pub fn new() -> Self {
Self(Rc::new(RefCell::new(VecDeque::new())))
}
/// Add a delay to the tail of the queue, or alter the value already there by the given amount.
/// A delay action's value can never be less than 0.0
/// TODO This really ought to be Duration
pub fn delay(&mut self, delay: f64) {
let mut q = self.0.borrow_mut();
if let Some(Action::Delay(r)) = q.back_mut() {
*r = (*r + delay).max(0.0)
} else {
q.push_back(Action::Delay(delay.max(0.0)))
}
}
/// Add an action to the tail of the queue
pub fn act(&mut self, action: Action) {
self.0.borrow_mut().push_back(action);
}
/// promote the most recently enqueued action to the front.
pub fn now(&mut self) {
let mut q = self.0.borrow_mut();
if let Some(back) = q.pop_back() {
q.push_front(back);
}
}
/// Drains object `i`'s output queue onto a target queue: first advances
/// leading `Delay` actions by dt, then drains actions into the target
/// queue until we run out (or hit another delay)
pub fn drain(&mut self, source: ObjectId, target: BoardQueue, mut dt: f64) {
let mut queue = self.0.borrow_mut();
loop {
match queue.front_mut() {
None => break, // No more actions, we're done
Some(Action::Delay(time)) => { // A delay, decrease it by dt
if dt < *time { // Delay is too long, bail out
*time -= dt;
break;
} else { // dt eats the delay, pop it and continue
dt -= *time;
queue.pop_front();
}
}
Some(_) => {
let action = queue.pop_front().unwrap();
target.borrow_mut().push(BoardAction { source, action });
}
}
}
}
/// Return whether this queue has an `Action::Delay` in the front
pub fn waiting(&mut self) -> bool {
matches!(self.0.borrow().front(), Some(Action::Delay(_)))
}
}
impl Registerable for ObjQueue {
fn register(engine: &mut Engine, error_sink: ErrorSink) {
engine.register_type_with_name::<ObjQueue>("Queue");
engine.register_get("length", |q: &mut ObjQueue| q.0.borrow().len() as i64);
engine.register_fn("clear", |q: &mut ObjQueue| q.0.borrow_mut().clear());
// peek(): front action as a Rhai map, or () if empty.
engine.register_fn("peek", |q: &mut ObjQueue| -> Dynamic {
q.0.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.0.borrow_mut()
.pop_front()
.as_ref()
.map(|a| Dynamic::from(action_to_map(a)))
.unwrap_or(Dynamic::UNIT)
});
// Appends a [`Action::Delay`] to the back of `queue`, merging with an existing
// trailing delay to prevent adjacent delays from accumulating.
engine.register_fn("delay", move |q: &mut ObjQueue, secs: Dynamic| {
let secs: Result<f64, &str> = if secs.is_float() {
secs.as_float()
} else if secs.is_int() {
secs.as_int().map(|i| i as f64)
} else {
Err("delay() must be an int or float")
};
match secs {
Ok(secs) => q.delay(secs),
Err(msg) => error_sink.error(msg.to_string())
}
});
}
}
+32
View File
@@ -0,0 +1,32 @@
use rhai::Engine;
use crate::api::board::BoardRef;
use crate::api::player::PlayerWithPos;
use crate::game::GameState;
use crate::script::Registerable;
use crate::utils::ErrorSink;
/// The host-provided context handed to every script hook for the duration of one
/// call. Currently just the player snapshot, but it exists so more host state can
/// be threaded through the `run_*` methods without changing each signature again.
#[derive(Clone)]
pub struct ScriptState {
pub player: PlayerWithPos,
pub board: BoardRef,
}
impl ScriptState {
pub fn from_game_state(game_state: &GameState) -> Self {
Self {
player: PlayerWithPos(game_state.player, game_state.board().player),
board: game_state.board_rc(),
}
}
}
impl Registerable for ScriptState {
fn register(engine: &mut Engine, _error_sink: ErrorSink) {
engine.register_type_with_name::<ScriptState>("State")
.register_get("player", |state: &mut ScriptState| state.player)
.register_get("board", |state: &mut ScriptState| state.board.clone());
}
}