diff --git a/docs/script-api.md b/docs/script-api.md new file mode 100644 index 0000000..045057f --- /dev/null +++ b/docs/script-api.md @@ -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 | 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(); + } +} +``` diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs new file mode 100644 index 0000000..e8ac909 --- /dev/null +++ b/kiln-core/src/action.rs @@ -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`] 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, bg: Option }, + /// Call `fn_name` on `target` object, optionally passing `arg`. + Send { target: ObjectId, fn_name: String, arg: Option }, +} + +// ── 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 { + 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 ──────────────────────────────────────────────────────── + +impl TryFrom 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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))) + } + } +} diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index a065017..4de04c0 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -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 = Vec::new(); let mut bumps: Vec<(ObjectId, i64)> = Vec::new(); + let mut sends: Vec<(ObjectId, String, Option)> = Vec::new(); let mut new_bubbles: Vec = 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(); } diff --git a/kiln-core/src/lib.rs b/kiln-core/src/lib.rs index 2804af2..df4966f 100644 --- a/kiln-core/src/lib.rs +++ b/kiln-core/src/lib.rs @@ -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; diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 87d4454..108f1b1 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -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>; -/// 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>>; -/// 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>>; -/// 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>>; -/// 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>>; /// 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, + tags: Vec, +} + +/// 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 { + 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, - /// One entry per object that has a successfully compiled script. objects: Vec, - /// 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 = HashMap::new(); let mut failed: HashSet = 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) { + 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 { + pub(crate) fn take_board_queue(&mut self) -> Vec { 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( &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"); + + 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"); + + 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::("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::("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"); @@ -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::("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::() { + 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 -} diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 170343f..49ce94b 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -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 diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index c003649..0e7aa0b 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -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. diff --git a/maps/start.toml b/maps/start.toml index d7f07d1..1d5ae9d 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -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 = """