better scripting api

This commit is contained in:
2026-06-09 22:38:17 -05:00
parent 9c854bc1b9
commit c2168b992d
8 changed files with 1199 additions and 300 deletions
+314
View File
@@ -0,0 +1,314 @@
# Kiln Script API Reference
Scripts are written in [Rhai](https://rhai.rs) and live in the `[scripts]` section of a `.toml` map
file. Each scripted object references a script by name via `script_name`. A script may define up to
three lifecycle hooks; any or all may be omitted.
---
## Lifecycle hooks
### `fn init()`
Called once for every scripted object after the entire map is loaded, before the first frame.
Use it to set up initial tile appearance, log a startup message, etc.
```rhai
fn init() {
set_tile(42);
log("ready");
}
```
### `fn tick(dt)`
Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Movement
and other timed actions pace themselves through the queue; you do not need to track time manually
for simple walking patterns.
```rhai
fn tick(dt) {
if Queue.length() == 0 && !blocked(North) {
move(North);
}
}
```
### `fn bump(id)`
Called when another entity walks into this object's cell.
- `id` is the bumper's `ObjectId` (an `i64`).
- `id == -1` means the player bumped this object.
```rhai
fn bump(id) {
if id == -1 {
say("Hey! Watch it.");
} else {
log(`object ${id} bumped me`);
}
}
```
---
## Constants in scope
These are pre-set in every object's scope and cannot be reassigned.
| Name | Type | Description |
|------|------|-------------|
| `Board` | `Board` | Read-only handle to the game world. |
| `Queue` | `Queue` | Handle to this object's own pending-action queue. |
| `North` | `Direction` | Cardinal direction: up (y 1). |
| `South` | `Direction` | Cardinal direction: down (y + 1). |
| `East` | `Direction` | Cardinal direction: right (x + 1). |
| `West` | `Direction` | Cardinal direction: left (x 1). |
| `MY_ID` | `i64` | This object's stable `ObjectId`. Use with `set_tag`. |
---
## Read functions
### `Board` getters
Access via `Board.<property>`:
| Property | Type | Description |
|----------|------|-------------|
| `Board.player_x` | `i64` | Player's current column (0-indexed). |
| `Board.player_y` | `i64` | Player's current row (0-indexed). |
| `Board.width` | `i64` | Board width in cells. |
| `Board.height` | `i64` | Board height in cells. |
```rhai
let dist_x = (Board.player_x - 30).abs();
```
### `blocked(dir) -> bool`
Returns `true` if moving in `dir` from this object's current cell would be impossible — because
the target cell is out of bounds, holds a solid, or another object has already queued a move there.
Note: the object's *own* queued moves are not yet on the board queue when `blocked` is called, so
an object cannot accidentally block itself.
```rhai
fn tick(dt) {
if !blocked(East) { move(East); }
}
```
### `has_tag(tag) -> bool`
Returns `true` if this object has the named tag.
```rhai
if has_tag("enemy") { say("I'm an enemy!"); }
```
### `get_tags() -> Array`
Returns an array of strings listing every tag on this object.
```rhai
for t in get_tags() { log(t); }
```
### `objects_with_tag(tag) -> Array`
Returns an array of `i64` object IDs for every object on the board that currently carries `tag`.
```rhai
let enemies = objects_with_tag("enemy");
log(`there are ${enemies.len()} enemies`);
```
### `my_name() -> String`
Returns this object's name string, or `""` if the object has no name.
```rhai
log(`I am ${my_name()}`);
```
### `object_id_for_name(name) -> i64`
Looks up an object by its name and returns its `ObjectId`. Returns `0` if no object has that name.
`0` is never a valid id, so it can be used as a null check.
```rhai
let door_id = object_id_for_name("door");
if door_id != 0 {
set_tag(door_id, "open", true);
}
```
---
## Write functions
Scripts do not mutate the world directly. Each write function appends an action to this object's
**output queue**. Actions are drained to the shared board queue between script calls and resolved
by the engine after the batch.
### `move(dir)`
Enqueues a move one cell in `dir`. Also inserts a `Delay` of 0.25 s after the move, limiting this
object to ~4 moves per second regardless of frame rate. A blocked move still costs the cooldown.
```rhai
fn tick(dt) {
if !blocked(South) { move(South); }
}
```
### `delay(secs)`
Inserts an explicit pause of `secs` seconds into this object's queue. Adjacent delays are merged
(you never get two consecutive `Delay` entries). Use it to slow down an action sequence or pause
between speech bubbles.
```rhai
fn init() {
say("One…");
delay(2.0);
say("Two!");
now(); // promote the last say() past the delay
}
```
### `now()`
Moves the most recently enqueued action to the **front** of the queue, bypassing any pending
delays. Useful after a zero-cost action (like `say` or `log`) to make it visible immediately even
if a move delay is in progress.
```rhai
fn bump(id) {
say("Ouch!");
now(); // show the bubble right away
}
```
### `set_tile(n)`
Sets this object's glyph tile index to `n` (an integer). The tile index corresponds to a CP437
code point when using the default kiln font.
```rhai
fn init() {
set_tile(1); // ☺
}
```
### `log(msg)`
Appends a plain-text line to the game log. The message is a string.
```rhai
log(`player is at ${Board.player_x}, ${Board.player_y}`);
```
### `say(msg)`
Displays a speech bubble above this object containing `msg`. The bubble lasts 3 seconds. If the
object already has an active bubble, it is replaced (text and timer reset). Zero-cost; combine with
`now()` to surface it immediately from a `bump` or `init` hook.
```rhai
fn bump(id) {
say("Hey!");
now();
}
```
### `set_tag(target_id, tag, present)`
Adds (`present = true`) or removes (`present = false`) a tag on any object identified by
`target_id`. Use `MY_ID` to mutate the calling object's own tags.
```rhai
// Add a tag to self
set_tag(MY_ID, "activated", true);
// Remove a tag from another object by name
let lever_id = object_id_for_name("lever");
set_tag(lever_id, "pulled", true);
```
---
## `Queue` object
Accessed via the `Queue` constant in scope. Provides inspection and control of this object's own
pending-action queue.
| Method | Returns | Description |
|--------|---------|-------------|
| `Queue.length()` | `i64` | Number of actions currently in the queue. |
| `Queue.clear()` | — | Discard all pending actions. |
Use `Queue.length() == 0` in `tick` to avoid queuing new moves while a previous one is in flight:
```rhai
fn tick(dt) {
if Queue.length() == 0 {
if !blocked(East) { move(East); }
else if !blocked(West) { move(West); }
}
}
```
---
## Rate limiting and timing
- `move(dir)` costs **0.25 s** of queue delay (~4 moves/second max).
- `delay(secs)` adds an arbitrary pause.
- `now()` can bypass a delay for zero-cost actions already in the queue.
- `Queue.clear()` cancels everything, including pending delays.
- Adjacent delays are always merged: two `delay(0.5)` calls become one `Delay(1.0)` entry.
---
## Error handling
Script errors (compile-time or runtime) are logged to the game log as plain-text lines and do not
crash the game. A script that throws an exception on `tick` will log the error and resume normally
on the next frame.
---
## Full example
```rhai
// A guard that patrols east/west and greets the player when bumped.
fn init() {
set_tile(2);
log(`${my_name()} standing watch`);
}
fn tick(dt) {
if Queue.length() > 0 { return; } // still moving
if !blocked(East) {
move(East);
} else if !blocked(West) {
move(West);
}
// Stuck in a corner — do nothing this tick.
}
fn bump(id) {
if id == -1 {
say("Halt! Who goes there?");
now();
} else {
say("Watch where you're going!");
now();
}
}
```
+364
View File
@@ -0,0 +1,364 @@
//! The [`Action`] enum and its Rhai-facing conversions.
//!
//! This module owns the `Action` type (which scripts enqueue via write host
//! functions), the rate-limiting delay constant, and the Rhai bridge that
//! lets scripts introspect and compose action queues via
//! `Queue.peek()`/`Queue.pop()`/`Queue.push()`.
//!
//! ## Rhai `"Action"` module
//!
//! [`rhai_action_module`] builds a static Rhai module registered as `"Action"`
//! on the engine. Scripts can import it and call constructor functions:
//!
//! ```rhai
//! import "Action" as A;
//! Queue.push(A::Move(North));
//! Queue.push(A::Say("Hello!"));
//! ```
//!
//! Constructors return Rhai maps (`#{ type: "...", ... }`). [`action_to_map`]
//! converts in the same direction for `Queue.peek()` / `Queue.pop()`.
//! [`TryFrom<rhai::Map>`] converts back, used by `Queue.push()`.
use crate::log::LogLine;
use crate::map_file::{color_to_hex, parse_color};
use crate::utils::{Direction, ObjectId, ScriptArg};
use color::Rgba8;
use rhai::{Dynamic, ImmutableString, Module};
/// How long a move occupies an object before it can act again, in seconds.
pub(crate) const MOVE_COST: f64 = 0.25;
/// One deferred mutation emitted by a script, applied by [`crate::game::GameState`]
/// after it is promoted onto the board queue.
///
/// [`Action::Delay`] is the exception: it is **never** promoted to the board queue.
/// It lives only in the per-object output queue and is consumed by `ScriptHost::drain`
/// to pace how quickly other actions are released.
pub(crate) enum Action {
/// 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 styled line to the game log.
Log(LogLine),
/// Add (`present = true`) or remove (`present = false`) `tag` on `target`.
SetTag { target: ObjectId, tag: String, present: bool },
/// Display a speech bubble above the source object for [`crate::game::SAY_DURATION`] seconds.
Say(String),
/// Pause draining this object's queue for `secs` seconds. Never reaches the board queue.
Delay(f64),
/// Set the source object's foreground and/or background color.
SetColor { fg: Option<Rgba8>, bg: Option<Rgba8> },
/// Call `fn_name` on `target` object, optionally passing `arg`.
Send { target: ObjectId, fn_name: String, arg: Option<ScriptArg> },
}
// ── Direction helpers ─────────────────────────────────────────────────────────
fn dir_to_str(d: Direction) -> &'static str {
match d {
Direction::North => "North",
Direction::South => "South",
Direction::East => "East",
Direction::West => "West",
}
}
fn str_to_dir(s: &str) -> Option<Direction> {
match s {
"North" => Some(Direction::North),
"South" => Some(Direction::South),
"East" => Some(Direction::East),
"West" => Some(Direction::West),
_ => None,
}
}
// ── action_to_map ─────────────────────────────────────────────────────────────
/// Converts an [`Action`] to the Rhai map form used by `Queue.peek()` / `Queue.pop()`.
///
/// The map always has a `"type"` key naming the variant; other keys carry the
/// payload. [`Action::Log`] is flattened to its first span's text.
pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
let mut m = rhai::Map::new();
match action {
Action::Move(dir) => {
ins_str(&mut m, "type", "Move");
ins_str(&mut m, "dir", dir_to_str(*dir));
}
Action::SetTile(n) => {
ins_str(&mut m, "type", "SetTile");
m.insert("tile".into(), Dynamic::from(*n as i64));
}
Action::Log(line) => {
let text = line.spans.first().map(|s| s.text.as_str()).unwrap_or("").to_string();
ins_str(&mut m, "type", "Log");
m.insert("msg".into(), Dynamic::from(text));
}
Action::SetTag { target, tag, present } => {
ins_str(&mut m, "type", "SetTag");
m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("tag".into(), Dynamic::from(tag.clone()));
m.insert("present".into(), Dynamic::from(*present));
}
Action::Say(text) => {
ins_str(&mut m, "type", "Say");
m.insert("msg".into(), Dynamic::from(text.clone()));
}
Action::Delay(secs) => {
ins_str(&mut m, "type", "Delay");
m.insert("secs".into(), Dynamic::from(*secs));
}
Action::SetColor { fg, bg } => {
ins_str(&mut m, "type", "SetColor");
if let Some(c) = fg { m.insert("fg".into(), Dynamic::from(color_to_hex(*c))); }
if let Some(c) = bg { m.insert("bg".into(), Dynamic::from(color_to_hex(*c))); }
}
Action::Send { target, fn_name, arg } => {
ins_str(&mut m, "type", "Send");
m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("name".into(), Dynamic::from(fn_name.clone()));
if let Some(a) = arg {
let dyn_arg = match a {
ScriptArg::Str(s) => Dynamic::from(s.clone()),
ScriptArg::Num(n) => Dynamic::from(*n),
};
m.insert("arg".into(), dyn_arg);
}
}
}
m
}
// ── TryFrom<rhai::Map> ────────────────────────────────────────────────────────
impl TryFrom<rhai::Map> for Action {
type Error = String;
/// Converts a Rhai map (as produced by the `"Action"` module constructors or
/// [`action_to_map`]) back into a Rust [`Action`]. Returns `Err(msg)` when
/// the map is structurally invalid or names an unknown variant.
fn try_from(map: rhai::Map) -> Result<Self, Self::Error> {
let type_str = map
.get("type")
.ok_or_else(|| "missing 'type' key".to_string())?
.clone()
.into_string()
.map_err(|_| "'type' must be a string".to_string())?;
match type_str.as_str() {
"Move" => {
let dir_str = get_str(&map, "dir", "Move")?;
let dir = str_to_dir(&dir_str)
.ok_or_else(|| format!("Move: unknown direction '{dir_str}'"))?;
Ok(Action::Move(dir))
}
"SetTile" => {
let n = get_int(&map, "tile", "SetTile")?;
Ok(Action::SetTile(n as u32))
}
"Log" => {
let msg = get_str(&map, "msg", "Log")?;
Ok(Action::Log(LogLine::raw(msg)))
}
"Say" => {
let msg = get_str(&map, "msg", "Say")?;
Ok(Action::Say(msg))
}
"Delay" => {
let secs = get_float(&map, "secs", "Delay")?;
Ok(Action::Delay(secs))
}
"SetTag" => {
let target = get_int(&map, "target", "SetTag")?;
let tag = get_str(&map, "tag", "SetTag")?;
let present = get_bool(&map, "present", "SetTag")?;
Ok(Action::SetTag { target: target as ObjectId, tag, present })
}
"SetColor" => {
let fg = opt_color(&map, "fg", "SetColor")?;
let bg = opt_color(&map, "bg", "SetColor")?;
Ok(Action::SetColor { fg, bg })
}
"Send" => {
let target = get_int(&map, "target", "Send")?;
let fn_name = get_str(&map, "name", "Send")?;
// Accept int, float, or string as the optional arg.
let arg = if let Some(v) = map.get("arg") {
let v = v.clone();
if let Ok(n) = v.clone().as_int() {
Some(ScriptArg::Num(n as f64))
} else if let Ok(f) = v.clone().as_float() {
Some(ScriptArg::Num(f))
} else if let Ok(s) = v.into_string() {
Some(ScriptArg::Str(s))
} else {
None // unrecognised arg type — ignored
}
} else {
None
};
Ok(Action::Send { target: target as ObjectId, fn_name, arg })
}
other => Err(format!("unknown action type: '{other}'")),
}
}
}
// ── Rhai "Action" module ──────────────────────────────────────────────────────
/// Builds the `"Action"` Rhai static module.
///
/// Register on the engine with
/// `engine.register_static_module("Action", rhai_action_module().into())`.
/// Scripts can then write `import "Action" as A; Queue.push(A::Move(North));`.
pub(crate) fn rhai_action_module() -> Module {
let mut m = Module::new();
m.set_native_fn("Move", |dir: Direction| {
let mut map = rhai::Map::new();
ins_str(&mut map, "type", "Move");
ins_str(&mut map, "dir", dir_to_str(dir));
Ok(Dynamic::from(map))
});
m.set_native_fn("Delay", |secs: f64| {
let mut map = rhai::Map::new();
ins_str(&mut map, "type", "Delay");
map.insert("secs".into(), Dynamic::from(secs));
Ok(Dynamic::from(map))
});
// Integer overload so A::Delay(2) works as well as A::Delay(2.0).
m.set_native_fn("Delay", |secs: i64| {
let mut map = rhai::Map::new();
ins_str(&mut map, "type", "Delay");
map.insert("secs".into(), Dynamic::from(secs as f64));
Ok(Dynamic::from(map))
});
m.set_native_fn("SetTile", |n: i64| {
let mut map = rhai::Map::new();
ins_str(&mut map, "type", "SetTile");
map.insert("tile".into(), Dynamic::from(n));
Ok(Dynamic::from(map))
});
m.set_native_fn("Log", |msg: ImmutableString| {
let mut map = rhai::Map::new();
ins_str(&mut map, "type", "Log");
map.insert("msg".into(), Dynamic::from(msg));
Ok(Dynamic::from(map))
});
m.set_native_fn("Say", |msg: ImmutableString| {
let mut map = rhai::Map::new();
ins_str(&mut map, "type", "Say");
map.insert("msg".into(), Dynamic::from(msg));
Ok(Dynamic::from(map))
});
// SetColor — fg only
m.set_native_fn("SetColor", |fg: ImmutableString| {
let mut map = rhai::Map::new();
ins_str(&mut map, "type", "SetColor");
map.insert("fg".into(), Dynamic::from(fg));
Ok(Dynamic::from(map))
});
// SetColor — fg + bg
m.set_native_fn("SetColor", |fg: ImmutableString, bg: ImmutableString| {
let mut map = rhai::Map::new();
ins_str(&mut map, "type", "SetColor");
map.insert("fg".into(), Dynamic::from(fg));
map.insert("bg".into(), Dynamic::from(bg));
Ok(Dynamic::from(map))
});
m.set_native_fn("SetTag", |target: i64, tag: ImmutableString, present: bool| {
let mut map = rhai::Map::new();
ins_str(&mut map, "type", "SetTag");
map.insert("target".into(), Dynamic::from(target));
map.insert("tag".into(), Dynamic::from(tag));
map.insert("present".into(), Dynamic::from(present));
Ok(Dynamic::from(map))
});
// Send — no arg
m.set_native_fn("Send", |target: i64, name: ImmutableString| {
let mut map = rhai::Map::new();
ins_str(&mut map, "type", "Send");
map.insert("target".into(), Dynamic::from(target));
map.insert("name".into(), Dynamic::from(name));
Ok(Dynamic::from(map))
});
// Send — with arg
m.set_native_fn("Send", |target: i64, name: ImmutableString, arg: Dynamic| {
let mut map = rhai::Map::new();
ins_str(&mut map, "type", "Send");
map.insert("target".into(), Dynamic::from(target));
map.insert("name".into(), Dynamic::from(name));
map.insert("arg".into(), arg);
Ok(Dynamic::from(map))
});
m
}
// ── Small helpers ─────────────────────────────────────────────────────────────
/// Inserts a `&'static str` value into a [`rhai::Map`] by converting via `String`.
/// Using `String` avoids the `From<&str>` lifetime restriction in rhai 1.x.
fn ins_str(m: &mut rhai::Map, key: &'static str, value: &'static str) {
m.insert(key.into(), Dynamic::from(value.to_string()));
}
fn get_str(map: &rhai::Map, key: &str, ctx: &str) -> Result<String, String> {
map.get(key)
.ok_or_else(|| format!("{ctx}: missing '{key}'"))?
.clone()
.into_string()
.map_err(|_| format!("{ctx}: '{key}' must be a string"))
}
fn get_int(map: &rhai::Map, key: &str, ctx: &str) -> Result<i64, String> {
map.get(key)
.ok_or_else(|| format!("{ctx}: missing '{key}'"))?
.clone()
.as_int()
.map_err(|_| format!("{ctx}: '{key}' must be an integer"))
}
fn get_float(map: &rhai::Map, key: &str, ctx: &str) -> Result<f64, String> {
let v = map.get(key)
.ok_or_else(|| format!("{ctx}: missing '{key}'"))?
.clone();
// Accept an integer literal passed where a float is expected.
v.clone()
.as_float()
.or_else(|_| v.as_int().map(|n| n as f64))
.map_err(|_| format!("{ctx}: '{key}' must be a number"))
}
fn get_bool(map: &rhai::Map, key: &str, ctx: &str) -> Result<bool, String> {
map.get(key)
.ok_or_else(|| format!("{ctx}: missing '{key}'"))?
.clone()
.as_bool()
.map_err(|_| format!("{ctx}: '{key}' must be a bool"))
}
/// Returns `Some(Rgba8)` if `key` is present in the map, or `None` if absent.
fn opt_color(map: &rhai::Map, key: &str, ctx: &str) -> Result<Option<Rgba8>, String> {
match map.get(key) {
None => Ok(None),
Some(v) => {
let s = v.clone()
.into_string()
.map_err(|_| format!("{ctx}: '{key}' must be a string"))?;
Ok(Some(parse_color(&s)))
}
}
}
+20 -5
View File
@@ -1,10 +1,11 @@
use crate::action::Action;
use crate::log::LogLine;
use crate::script::ScriptHost;
use std::cell::{Ref, RefCell, RefMut};
use std::rc::Rc;
use std::time::Duration;
use crate::board::Board;
use crate::utils::{Action, Direction, ObjectId, Solid};
use crate::utils::{Direction, ObjectId, ScriptArg, Solid};
/// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0;
@@ -105,17 +106,19 @@ impl GameState {
}
/// Resolves the board queue: applies each promoted action to the board, then fires
/// the `bump` hooks the moves triggered.
/// the `bump` and `send` hooks the moves triggered.
///
/// Done in two phases so no `board_mut` borrow is held while `bump` scripts run
/// Done in two phases so no `board_mut` borrow is held while scripts run
/// (they read the board through its getters): phase A mutates the board and records
/// `(bumped_object, bumper)` pairs; phase B fires the bumps after the borrow drops.
/// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B fires the
/// hooks after the borrow drops.
fn resolve(&mut self) {
let actions = self.scripts.take_board_queue();
// Logs are collected here rather than pushed inline, since the board borrow
// below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new();
let mut bumps: Vec<(ObjectId, i64)> = Vec::new();
let mut sends: Vec<(ObjectId, String, Option<ScriptArg>)> = Vec::new();
let mut new_bubbles: Vec<SpeechBubble> = Vec::new();
{
let mut board = self.board_mut();
@@ -146,7 +149,16 @@ impl GameState {
}),
// Delays are consumed by ScriptHost::drain and never reach the board queue.
Action::Delay(_) => {}
Action::SetColor { fg, bg } => {
if let Some(obj) = board.objects.get_mut(&ba.source) {
if let Some(c) = fg { obj.glyph.fg = c; }
if let Some(c) = bg { obj.glyph.bg = c; }
}
}
// Collected and fired after the board borrow drops, like bumps.
Action::Send { target, fn_name, arg } => {
sends.push((target, fn_name, arg));
}
}
}
}
@@ -159,6 +171,9 @@ impl GameState {
for (bumped, bumper) in bumps {
self.scripts.run_bump(bumped, bumper);
}
for (target, fn_name, arg) in sends {
self.scripts.run_send(target, &fn_name, arg);
}
self.drain_errors();
}
+1
View File
@@ -9,6 +9,7 @@ pub mod map_file;
/// Rhai scripting runtime for board objects ([`script::ScriptHost`]).
pub mod script;
pub mod glyph;
mod action;
mod font;
mod utils;
mod archetype;
+471 -257
View File
@@ -9,186 +9,217 @@
//! - `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
//! ## Script constants in scope
//!
//! 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).
//! Every object scope contains:
//!
//! 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.
//! | Name | Type | Description |
//! |---|---|---|
//! | `Board` | `Board` | Read-only world handle. |
//! | `Queue` | `Queue` | This object's pending-action queue. |
//! | `Me` | `Me` | Self-reference (id, name, x, y, tags, glyph). |
//! | `North`/`South`/`East`/`West` | `Direction` | Cardinal directions. |
//! | `Black`..`White` | `String` | 16 EGA/VGA color hex strings. |
//!
//! 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.
//! ## Board read API (`Board.*`)
//!
//! 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.
//! - `Board.player_x / player_y / width / height` — world properties
//! - `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
//!
//! [`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`]).
//! ## Self-reference API (`Me.*`)
//!
//! [`take_board_queue`]: ScriptHost::take_board_queue
//! [`GameState`]: crate::game::GameState
//! - `Me.id / name / x / y` — getters
//! - `Me.has_tag(s) -> bool`
//! - `Me.tags() -> Array`
//! - `Me.glyph() -> Glyph` (`Glyph.tile`, `Glyph.fg`, `Glyph.bg`)
//!
//! ## 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)`,
//! `send(target_id, fn_name [, arg])`
//!
//! ## Queue API
//!
//! `Queue.length()`, `Queue.clear()`, `Queue.peek()`, `Queue.pop()`, `Queue.push(map)`
use crate::action::{action_to_map, rhai_action_module, Action, MOVE_COST};
use crate::board::Board;
use crate::log::LogLine;
use rhai::{CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope, AST};
use crate::map_file::{color_to_hex, parse_color};
use crate::utils::{Direction, ObjectId, ScriptArg};
use rhai::{CallFnOptions, Dynamic, 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 {
pub(crate) struct BoardAction {
/// The stable [`ObjectId`] of the object that issued the action.
pub source: ObjectId,
pub(crate) source: ObjectId,
/// The action to apply.
pub action: Action,
pub(crate) 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.
/// A read-only handle to the world, exposed to scripts as `Board`.
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).
/// A single object's output queue.
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.
/// The board queue: actions promoted from object queues, awaiting resolution.
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.
/// Maps an [`ObjectId`] to its output queue.
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).
/// Channel for engine/compile/runtime errors.
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.
/// The runtime state of one scripted object.
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,
}
// ── Rhai types exposed to scripts ────────────────────────────────────────────
/// 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.
#[derive(Clone)]
struct ObjectInfo {
id: i64,
x: i64,
y: i64,
name: Option<String>,
tags: Vec<String>,
}
/// The glyph (tile + colors) of a board object, returned by `Me.glyph()`.
/// Colors are `"#RRGGBB"` hex strings matching the map-file format.
#[derive(Clone)]
struct RhaiGlyph {
tile: i64,
fg: String,
bg: String,
}
/// A script's read-only reference to itself, pushed into scope as the constant `Me`.
/// Holds the object id and a shared board handle so getters can look up live values.
#[derive(Clone)]
struct Me {
object_id: ObjectId,
board: BoardRef,
}
// ── ObjectInfo helper ─────────────────────────────────────────────────────────
fn object_info_from(id: ObjectId, board: &Board) -> Option<ObjectInfo> {
let obj = board.objects.get(&id)?;
Some(ObjectInfo {
id: id as i64,
x: obj.x as i64,
y: obj.y as i64,
name: obj.name.clone(),
tags: obj.tags.iter().cloned().collect(),
})
}
fn object_info_player(board: &Board) -> ObjectInfo {
ObjectInfo {
id: -1,
x: board.player.x as i64,
y: board.player.y as i64,
name: None,
tags: Vec::new(),
}
}
// ── ScriptHost ────────────────────────────────────────────────────────────────
/// 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,
/// Builds a host for the board behind `board_cell`: registers the full script 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.
/// unknown scripts are queued onto the error sink; 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);
engine.register_static_module("Action", rhai_action_module().into());
register_read_api(&mut engine, board_cell, &board_queue, &errors);
register_write_api(&mut engine, &queues);
register_queue_api(&mut engine);
register_queue_api(&mut engine, &errors);
register_me_type(&mut engine);
register_object_info_type(&mut engine);
register_glyph_type(&mut engine);
let board = board_cell.borrow();
// Compile each referenced script once; attribute failures to the first
// object that references the script.
// Compile each referenced script once.
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;
}
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)
ast.iter_functions().any(|f| f.name == n && f.params.len() == params)
};
let compiled = CompiledScript {
scripts.insert(name.clone(), 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!(
errors.borrow_mut().push(LogLine::error(format!(
"script '{name}' failed to compile: {err}"
)));
}
},
None => {
failed.insert(name.clone());
errors.borrow_mut().push(LogLine::raw(format!(
errors.borrow_mut().push(LogLine::error(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.
// One runtime per object whose script compiled.
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 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 {
@@ -200,35 +231,22 @@ impl ScriptHost {
}
drop(board);
Self {
engine,
scripts,
objects,
board_queue,
errors,
}
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).
/// Calls `init()` on every scripted object that defines it and drains each queue.
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`.
/// Calls `tick(dt)` on every scripted object that defines it, then drains queues.
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.
/// 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, 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;
};
@@ -241,17 +259,64 @@ impl ScriptHost {
if let Err(err) = engine.call_fn_with_options::<()>(
options, &mut obj.scope, &compiled.ast, "bump", (bumper,),
) {
errors.borrow_mut().push(LogLine::raw(format!(
errors.borrow_mut().push(LogLine::error(format!(
"script '{}' bump error: {err}", obj.script_name
)));
}
}
// dt=0: flush zero-cost front actions without advancing any delays.
self.drain(i, 0.0);
}
/// Calls the named function on the object with [`ObjectId`] `target_id`.
///
/// If `arg` is `Some` and the function accepts one parameter, the arg is passed.
/// If the function accepts zero parameters (or arg is `None`), it is called with
/// no args. If neither arity exists, the call is silently skipped.
/// After the call, drains the object's queue with `dt = 0`.
pub(crate) fn run_send(&mut self, target_id: ObjectId, fn_name: &str, arg: Option<ScriptArg>) {
let Some(i) = self.objects.iter().position(|o| o.object_id == target_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; };
let has_1 = compiled.ast.iter_functions()
.any(|f| f.name == fn_name && f.params.len() == 1);
let has_0 = compiled.ast.iter_functions()
.any(|f| f.name == fn_name && f.params.len() == 0);
let options = CallFnOptions::default().with_tag(obj.object_id as i64);
let result = if has_1 {
// Call with arg (or unit if arg is absent).
let dyn_arg: Dynamic = match &arg {
Some(ScriptArg::Str(s)) => Dynamic::from(s.clone()),
Some(ScriptArg::Num(n)) => Dynamic::from(*n),
None => Dynamic::UNIT,
};
engine.call_fn_with_options::<()>(
options, &mut obj.scope, &compiled.ast, fn_name, (dyn_arg,),
)
} else if has_0 {
engine.call_fn_with_options::<()>(
options, &mut obj.scope, &compiled.ast, fn_name, (),
)
} else {
return; // Function not defined on this object
};
if let Err(err) = result {
errors.borrow_mut().push(LogLine::error(format!(
"script '{}' send('{}') error: {err}", obj.script_name, fn_name
)));
}
}
self.drain(i, 0.0);
}
/// Removes and returns the actions promoted onto the board queue.
pub fn take_board_queue(&mut self) -> Vec<BoardAction> {
pub(crate) fn take_board_queue(&mut self) -> Vec<BoardAction> {
std::mem::take(&mut self.board_queue.borrow_mut())
}
@@ -262,31 +327,23 @@ impl ScriptHost {
/// 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
dt = 0.0;
*r <= 0.0
};
if expired {
queue.borrow_mut().pop_front();
// Continue — drain actions after the expired delay.
} else {
break; // Live delay: stop draining.
break;
}
} else {
let Some(action) = queue.borrow_mut().pop_front() else { break };
@@ -295,10 +352,7 @@ impl ScriptHost {
}
}
/// 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.
/// Shared driver for `init`/`tick`.
fn run<A: FuncArgs + Copy>(
&mut self,
hook: &str,
@@ -307,7 +361,6 @@ impl ScriptHost {
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];
@@ -318,7 +371,7 @@ impl ScriptHost {
if let Err(err) = engine.call_fn_with_options::<()>(
options, &mut obj.scope, &compiled.ast, hook, args,
) {
errors.borrow_mut().push(LogLine::raw(format!(
errors.borrow_mut().push(LogLine::error(format!(
"script '{}' {hook} error: {err}", obj.script_name
)));
}
@@ -329,91 +382,155 @@ impl ScriptHost {
}
}
/// 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) {
// ── Register Me type ──────────────────────────────────────────────────────────
fn register_me_type(engine: &mut Engine) {
engine.register_type_with_name::<Me>("Me");
engine.register_get("id", |me: &mut Me| me.object_id as i64);
engine.register_get("name", |me: &mut Me| {
me.board.borrow()
.objects.get(&me.object_id)
.and_then(|o| o.name.as_deref())
.unwrap_or("")
.to_string()
});
engine.register_get("x", |me: &mut Me| {
me.board.borrow()
.objects.get(&me.object_id)
.map(|o| o.x as i64)
.unwrap_or(0)
});
engine.register_get("y", |me: &mut Me| {
me.board.borrow()
.objects.get(&me.object_id)
.map(|o| o.y as i64)
.unwrap_or(0)
});
engine.register_fn("has_tag", |me: &mut Me, tag: ImmutableString| {
me.board.borrow()
.objects.get(&me.object_id)
.is_some_and(|o| o.tags.contains(tag.as_str()))
});
engine.register_fn("tags", |me: &mut Me| -> rhai::Array {
me.board.borrow()
.objects.get(&me.object_id)
.map(|o| o.tags.iter().map(|t| Dynamic::from(t.clone())).collect())
.unwrap_or_default()
});
engine.register_fn("glyph", |me: &mut Me| -> RhaiGlyph {
let board = me.board.borrow();
if let Some(obj) = board.objects.get(&me.object_id) {
RhaiGlyph {
tile: obj.glyph.tile as i64,
fg: color_to_hex(obj.glyph.fg),
bg: color_to_hex(obj.glyph.bg),
}
} else {
RhaiGlyph { tile: 0, fg: "#000000".to_string(), bg: "#000000".to_string() }
}
});
}
// ── Register ObjectInfo type ──────────────────────────────────────────────────
fn register_object_info_type(engine: &mut Engine) {
engine.register_type_with_name::<ObjectInfo>("ObjectInfo");
engine.register_get("id", |o: &mut ObjectInfo| o.id);
engine.register_get("x", |o: &mut ObjectInfo| o.x);
engine.register_get("y", |o: &mut ObjectInfo| o.y);
engine.register_get("name", |o: &mut ObjectInfo| {
o.name.as_deref().unwrap_or("").to_string()
});
engine.register_get("tags", |o: &mut ObjectInfo| -> rhai::Array {
o.tags.iter().map(|t| Dynamic::from(t.clone())).collect()
});
}
// ── Register RhaiGlyph type ───────────────────────────────────────────────────
fn register_glyph_type(engine: &mut Engine) {
engine.register_type_with_name::<RhaiGlyph>("Glyph");
engine.register_get("tile", |g: &mut RhaiGlyph| g.tile);
engine.register_get("fg", |g: &mut RhaiGlyph| g.fg.clone());
engine.register_get("bg", |g: &mut RhaiGlyph| g.bg.clone());
}
// ── Board read API ────────────────────────────────────────────────────────────
fn register_read_api(
engine: &mut Engine,
board: &BoardRef,
board_queue: &BoardQueue,
errors: &ErrorSink,
) {
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);
engine.register_get("width", |b: &mut BoardRef| b.borrow().width as i64);
engine.register_get("height", |b: &mut BoardRef| b.borrow().height as i64);
// blocked(dir) — true if moving in dir would be impossible for the calling object.
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?
// Board.tagged(tag) -> Array[ObjectInfo]
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()))
engine.register_fn("tagged", move |_board: &mut BoardRef, tag: ImmutableString| -> rhai::Array {
let board = b.borrow();
board.objects.iter()
.filter(|(_, o)| o.tags.contains(tag.as_str()))
.filter_map(|(&id, _)| object_info_from(id, &board))
.map(Dynamic::from)
.collect()
});
// get_tags() -> Array of strings: the calling object's current tag set.
// Board.named(name) -> ObjectInfo | ()
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()
engine.register_fn("named", move |_board: &mut BoardRef, name: ImmutableString| -> Dynamic {
let board = b.borrow();
board.objects.iter()
.find(|(_, o)| o.name.as_deref() == Some(name.as_str()))
.and_then(|(&id, _)| object_info_from(id, &board))
.map(Dynamic::from)
.unwrap_or(Dynamic::UNIT)
});
// objects_with_tag(s) -> Array of i64: ids of all objects carrying the tag.
// Board.get(id) -> ObjectInfo | () (-1 returns player; unknown id logs error)
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()
let errs = errors.clone();
engine.register_fn("get", move |_board: &mut BoardRef, id: i64| -> Dynamic {
let board = b.borrow();
if id == -1 {
return Dynamic::from(object_info_player(&board));
}
if id <= 0 {
errs.borrow_mut().push(LogLine::error(format!("Board.get: invalid id {id}")));
return Dynamic::UNIT;
}
match object_info_from(id as ObjectId, &board) {
Some(info) => Dynamic::from(info),
None => {
errs.borrow_mut().push(LogLine::error(format!("Board.get: no object with id {id}")));
Dynamic::UNIT
}
}
});
// 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).
/// Whether the object at `source` would be blocked moving in `dir`.
fn is_blocked(
board: &BoardRef,
board_queue: &BoardQueue,
@@ -421,19 +538,12 @@ fn is_blocked(
dir: Direction,
) -> bool {
let board = board.borrow();
let Some(obj) = board.objects.get(&source) else {
return true;
};
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;
}
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.
if board.solid_at(tx, ty).is_some() { return true; }
board_queue.borrow().iter().any(|ba| {
if let Action::Move(d) = &ba.action
&& let Some(mover) = board.objects.get(&ba.source)
@@ -445,12 +555,8 @@ fn is_blocked(
})
}
/// 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.
// ── Write API ─────────────────────────────────────────────────────────────────
fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
engine.register_type_with_name::<Direction>("Direction");
@@ -464,7 +570,7 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
}
});
// delay(secs): insert an explicit pause into the queue, merged with any trailing Delay.
// delay(secs): insert an explicit pause. Two overloads so integer literals work.
let q = queues.clone();
engine.register_fn("delay", move |ctx: NativeCallContext, secs: f64| {
let src = source_of(&ctx);
@@ -472,9 +578,15 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
push_delay(queue, secs.max(0.0));
}
});
let q = queues.clone();
engine.register_fn("delay", move |ctx: NativeCallContext, secs: i64| {
let src = source_of(&ctx);
if let Some(queue) = q.borrow().get(&src) {
push_delay(queue, (secs.max(0)) as f64);
}
});
// 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.
// now(): promote the most recently enqueued action to the front.
let q = queues.clone();
engine.register_fn("now", move |ctx: NativeCallContext| {
let src = source_of(&ctx);
@@ -492,53 +604,173 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
});
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())),
);
},
);
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()));
},
);
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,
},
);
emit(&q, source_of(&ctx), Action::SetTag {
target: target as ObjectId,
tag: tag.to_string(),
present,
});
},
);
// set_fg(fg): change foreground color only.
let q = queues.clone();
engine.register_fn("set_fg", move |ctx: NativeCallContext, fg: ImmutableString| {
emit(&q, source_of(&ctx), Action::SetColor {
fg: Some(parse_color(fg.as_str())),
bg: None,
});
});
// set_bg(bg): change background color only.
let q = queues.clone();
engine.register_fn("set_bg", move |ctx: NativeCallContext, bg: ImmutableString| {
emit(&q, source_of(&ctx), Action::SetColor {
fg: None,
bg: Some(parse_color(bg.as_str())),
});
});
// set_color(fg, bg): change both colors.
let q = queues.clone();
engine.register_fn(
"set_color",
move |ctx: NativeCallContext, fg: ImmutableString, bg: ImmutableString| {
emit(&q, source_of(&ctx), Action::SetColor {
fg: Some(parse_color(fg.as_str())),
bg: Some(parse_color(bg.as_str())),
});
},
);
// send(target, fn_name): call a named function on another object.
let q = queues.clone();
engine.register_fn(
"send",
move |ctx: NativeCallContext, target: i64, name: ImmutableString| {
emit(&q, source_of(&ctx), Action::Send {
target: target as ObjectId,
fn_name: name.to_string(),
arg: None,
});
},
);
// send(target, fn_name, arg): same, with an optional string or number argument.
let q = queues.clone();
engine.register_fn(
"send",
move |ctx: NativeCallContext, target: i64, name: ImmutableString, arg: Dynamic| {
let script_arg = if let Ok(n) = arg.clone().as_int() {
Some(ScriptArg::Num(n as f64))
} else if let Ok(f) = arg.clone().as_float() {
Some(ScriptArg::Num(f))
} else if let Ok(s) = arg.into_string() {
Some(ScriptArg::Str(s))
} else {
None
};
emit(&q, source_of(&ctx), Action::Send {
target: target as ObjectId,
fn_name: name.to_string(),
arg: script_arg,
});
},
);
}
/// 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) {
// ── Queue API ─────────────────────────────────────────────────────────────────
fn register_queue_api(engine: &mut Engine, errors: &ErrorSink) {
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());
engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear());
// peek(): front action as a Rhai map, or () if empty.
engine.register_fn("peek", |q: &mut ObjQueue| -> Dynamic {
q.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.borrow_mut()
.pop_front()
.as_ref()
.map(|a| Dynamic::from(action_to_map(a)))
.unwrap_or(Dynamic::UNIT)
});
// push(map): parse the map into an Action and enqueue; log an error on failure.
let errs = errors.clone();
// We need the queues map here to route push to the right object's queue.
// Since push() is called on the Queue handle directly, we use that handle.
engine.register_fn("push", move |q: &mut ObjQueue, map: Dynamic| {
let rhai_map = match map.try_cast::<rhai::Map>() {
Some(m) => m,
None => {
errs.borrow_mut().push(LogLine::error(
"Queue.push: argument must be an Action map".to_string(),
));
return;
}
};
match Action::try_from(rhai_map) {
Ok(action) => q.borrow_mut().push_back(action),
Err(msg) => errs.borrow_mut().push(LogLine::error(format!("Queue.push: {msg}"))),
}
});
}
// ── Scope construction ────────────────────────────────────────────────────────
/// Builds a fresh per-object scope with the Board view, Queue, Me, direction
/// constants, and the 16 EGA/VGA named color constants.
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("Me", Me { object_id, 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);
// 16 EGA/VGA color constants as "#RRGGBB" strings.
scope.push_constant("Black", "#000000");
scope.push_constant("Blue", "#0000AA");
scope.push_constant("Green", "#00AA00");
scope.push_constant("Cyan", "#00AAAA");
scope.push_constant("Red", "#AA0000");
scope.push_constant("Magenta", "#AA00AA");
scope.push_constant("Brown", "#AA5500");
scope.push_constant("LightGray", "#AAAAAA");
scope.push_constant("DarkGray", "#555555");
scope.push_constant("BrightBlue", "#5555FF");
scope.push_constant("BrightGreen", "#55FF55");
scope.push_constant("BrightCyan", "#55FFFF");
scope.push_constant("BrightRed", "#FF5555");
scope.push_constant("BrightMagenta","#FF55FF");
scope.push_constant("Yellow", "#FFFF55");
scope.push_constant("White", "#FFFFFF");
scope
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// 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) {
@@ -557,28 +789,10 @@ fn emit(queues: &QueueMap, source: ObjectId, action: 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.
/// Reads the issuing object's [`ObjectId`] from the call tag.
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
}
+15 -14
View File
@@ -119,11 +119,11 @@ fn start_map_greeter_runs_init() {
#[test]
fn set_tag_adds_and_removes_via_my_id() {
// A script calls set_tag(MY_ID, "active", true) in init; the tag must be
// A script calls set_tag(Me.id, "active", true) in init; the tag must be
// present on the object afterward.
let board = board_with_object(
Some("t"),
&[("t", r#"fn init() { set_tag(MY_ID, "active", true); }"#)],
&[("t", r#"fn init() { set_tag(Me.id, "active", true); }"#)],
);
let mut game = GameState::new(board);
game.run_init();
@@ -132,7 +132,7 @@ fn set_tag_adds_and_removes_via_my_id() {
// A script removes a pre-existing tag.
let board2 = board_with_object(
Some("t2"),
&[("t2", r#"fn init() { set_tag(MY_ID, "active", false); }"#)],
&[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)],
);
// Seed the tag before construction.
let mut game2 = GameState::new({
@@ -152,8 +152,8 @@ fn has_tag_reads_own_tags() {
Some("t"),
&[(
"t",
r#"fn init() { set_tag(MY_ID, "active", true); }
fn tick(dt) { log(has_tag("active").to_string()); }"#,
r#"fn init() { set_tag(Me.id, "active", true); }
fn tick(dt) { log(Me.has_tag("active").to_string()); }"#,
)],
);
let mut game = GameState::new(board);
@@ -177,9 +177,9 @@ fn objects_with_tag_returns_matching_ids() {
vec![obj1, obj2],
&[
("q", r#"fn init() {
let ids = objects_with_tag("enemy");
log(ids.len().to_string());
log(ids[0].to_string());
let infos = Board.tagged("enemy");
log(infos.len().to_string());
log(infos[0].id.to_string());
}"#),
("none", ""),
],
@@ -196,7 +196,7 @@ fn my_name_returns_name_or_empty_string() {
// An object with a name set on its ObjectDef should see it via my_name().
let mut board = board_with_object(
Some("n"),
&[("n", r#"fn init() { log(my_name()); }"#)],
&[("n", r#"fn init() { log(Me.name); }"#)],
);
board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string());
let mut game = GameState::new(board);
@@ -206,7 +206,7 @@ fn my_name_returns_name_or_empty_string() {
// An unnamed object should get an empty string.
let board2 = board_with_object(
Some("n"),
&[("n", r#"fn init() { log(my_name()); }"#)],
&[("n", r#"fn init() { log(Me.name); }"#)],
);
let mut game2 = GameState::new(board2);
game2.run_init();
@@ -227,8 +227,9 @@ fn object_id_for_name_finds_by_name() {
vec![obj1, obj2],
&[
("q", r#"fn init() {
log(object_id_for_name("target").to_string());
log(object_id_for_name("missing").to_string());
log(Board.named("target").id.to_string());
let miss = Board.named("missing");
log(if miss == () { "not_found" } else { miss.id.to_string() });
}"#),
("none", ""),
],
@@ -236,8 +237,8 @@ fn object_id_for_name_finds_by_name() {
let mut game = GameState::new(board);
game.run_init();
let texts = log_texts(&game);
assert_eq!(texts[0], "2"); // obj2 is id 2
assert_eq!(texts[1], "0"); // 0 = not found
assert_eq!(texts[0], "2"); // obj2 is id 2
assert_eq!(texts[1], "not_found"); // Board.named returns () when no match
}
// Ensure try_move from the player side also triggers scripted bump
+6 -24
View File
@@ -1,6 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::archetype::Archetype;
use crate::log::LogLine;
/// Which directions a solid may be pushed in.
///
@@ -134,29 +133,12 @@ mod tests {
}
}
/// How long a move occupies an object before it can act again, in seconds.
pub const MOVE_COST: f64 = 0.25;
/// One deferred mutation emitted by a script, applied by [`crate::game::GameState`]
/// after it is promoted onto the board queue.
///
/// [`Action::Delay`] is the exception: it is **never** promoted to the board queue.
/// It lives only in the per-object output queue and is consumed by [`ScriptHost::drain`]
/// to pace how quickly other actions are released.
pub enum Action {
/// 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),
/// Add (`present = true`) or remove (`present = false`) `tag` on `target`.
SetTag { target: ObjectId, tag: String, present: bool },
/// Display a speech bubble above the source object for [`crate::game::SAY_DURATION`] seconds.
Say(String),
/// Pause draining this object's queue for `secs` seconds. Never reaches the board queue.
/// Adjacent delays are merged at push time (see [`script::push_delay`]).
Delay(f64),
/// An optional argument passed to a script function via [`crate::action::Action::Send`].
pub(crate) enum ScriptArg {
/// A string argument.
Str(String),
/// A numeric argument (stored as `f64` to cover both integer and float callers).
Num(f64),
}
/// A cardinal movement direction.
+8
View File
@@ -109,6 +109,14 @@ fn init() {
set_tile(2); // change my glyph to ☻ — proves the write path
say("Hello there, traveller!");
}
fn tick(dt) {
//log(`x: ${Me.x} ${Board.player_x} y: ${Me.y} ${Board.player_y} q: ${Queue.length()}`);
if Board.player_x == Me.x && Board.player_y == Me.y && Queue.length() == 0 {
say("Hey! Get offa me!");
delay(5.0);
}
}
"""
mover = """