2026-06-09 22:38:17 -05:00
|
|
|
//! 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;
|
|
|
|
|
|
2026-06-10 01:42:33 -05:00
|
|
|
/// One line of content in a [`Action::Scroll`] overlay.
|
|
|
|
|
///
|
|
|
|
|
/// Each element of the array passed to the `scroll()` Rhai function becomes a
|
|
|
|
|
/// `ScrollLine`: a plain string becomes [`Text`](ScrollLine::Text); a two-element
|
|
|
|
|
/// array `[choice, display]` becomes a [`Choice`](ScrollLine::Choice).
|
|
|
|
|
pub enum ScrollLine {
|
|
|
|
|
/// Plain text, word-wrapped to the scroll window width.
|
|
|
|
|
Text(String),
|
|
|
|
|
/// A selectable option: `choice` is the event name sent back to the source
|
|
|
|
|
/// object via `send`, and `display` is the label shown to the player.
|
|
|
|
|
Choice { choice: String, display: String },
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 22:38:17 -05:00
|
|
|
/// 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> },
|
2026-06-10 01:42:33 -05:00
|
|
|
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice
|
|
|
|
|
/// selection dispatches [`Send`](Action::Send) to the source object.
|
|
|
|
|
Scroll(Vec<ScrollLine>),
|
2026-06-09 22:38:17 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-10 01:42:33 -05:00
|
|
|
// Scroll lines are not round-trippable via the queue map API; emit type only.
|
|
|
|
|
Action::Scroll(_) => {
|
|
|
|
|
ins_str(&mut m, "type", "Scroll");
|
|
|
|
|
}
|
2026-06-09 22:38:17 -05:00
|
|
|
}
|
|
|
|
|
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 })
|
|
|
|
|
}
|
2026-06-10 01:42:33 -05:00
|
|
|
"Scroll" => Err("Scroll actions cannot be constructed from maps".to_string()),
|
2026-06-09 22:38:17 -05:00
|
|
|
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)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|