//! 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 [`action_to_map`] which //! converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`. use std::fmt::Debug; use crate::log::LogLine; use crate::map_file::color_to_hex; use crate::utils::{Direction, ObjectId}; use color::Rgba8; use rhai::Dynamic; /// How long a move occupies an object before it can act again, in seconds. pub(crate) const MOVE_COST: f64 = 0.25; /// 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). #[derive(Clone)] 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 }, } #[derive(Clone, PartialEq)] pub enum SendArg { None, Int(i64), Float(f64), String(String), } impl From for SendArg { fn from(value: Dynamic) -> Self { if value.is_int() { Self::Int(value.as_int().unwrap()) } else if value.is_float() { Self::Float(value.as_float().unwrap()) } else if value.is_string() { Self::String(value.into_string().unwrap()) } else { Self::None } } } impl Into for SendArg { fn into(self) -> Dynamic { match self { SendArg::None => Dynamic::UNIT, SendArg::Int(value) => Dynamic::from(value), SendArg::Float(value) => Dynamic::from(value), SendArg::String(value) => Dynamic::from(value), } } } /// 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. #[derive(Clone)] 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 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 `duration` seconds. Say(String, f64), /// 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: SendArg, }, /// Open a scrollable text overlay. Pauses game ticks while shown; a choice /// selection dispatches [`Send`](Action::Send) to the source object. Scroll(Vec), /// Teleport the source object to `(x, y)`. Blocked (with a logged error) if /// the source is solid and the destination already has a solid occupant. /// Zero time cost — multiple teleports may fire in one frame. Teleport { x: i64, y: i64 }, /// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on /// arbitrary cells, not the source object). Zero time cost. Push { x: i64, y: i64, dir: Direction }, /// Shift a set of cells, given as `(x, y)` coordinates: each cell moves to the /// next coordinate in the list, unless it can't move, or that cell is blocked. /// Rotates the contents of the last cell back to the beginning. /// Zero time cost. Shift(Vec<(i64, i64)>), /// Add `n` to the player's gem count (negative subtracts; the count is /// clamped at 0). Zero time cost. Applied to `GameState::player.gems`. AddGems(i64), /// Add `dh` to the player's health (clamped to `[0, max_health]`). Zero time /// cost. Applied to `GameState::player.health`. AlterHealth(i64), /// Give (`true`) or take (`false`) the named key color from the player. /// /// Color must be one of `"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`, /// `"orange"`, `"yellow"`, `"white"`. An unrecognized name is logged and /// ignored. Zero time cost. Applied to `GameState::player.keys`. SetKey(String, bool), /// Remove the source object from the board. Zero time cost. Used by grab /// things (e.g. gems) to despawn themselves from their `grab()` hook. Die, } impl Debug for Action { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Action::Move(dir) => write!(f, "Move({:?})", dir), Action::SetTile(i) => write!(f, "SetTile({i})"), Action::Log(msg) => write!(f, "Log({:?})", msg), Action::SetTag { .. } => write!(f, "SetTag"), Action::Say(_, _) => write!(f, "Say"), Action::Delay(t) => write!(f, "Delay({t})"), Action::SetColor { .. } => write!(f, "SetColor"), Action::Send { .. } => write!(f, "Send"), Action::Scroll(_) => write!(f, "Scroll"), Action::Teleport { .. } => write!(f, "Teleport"), Action::Push { .. } => write!(f, "Push"), Action::Shift(_) => write!(f, "Shift"), Action::AddGems(n) => write!(f, "AddGems({n}"), Action::AlterHealth(health) => write!(f, "AlterHealth({health})"), Action::SetKey(_, _) => write!(f, "SetKey"), Action::Die => write!(f, "Die") } } } // ── Direction helper ────────────────────────────────────────────────────────── fn dir_to_str(d: Direction) -> &'static str { match d { Direction::North => "North", Direction::South => "South", Direction::East => "East", Direction::West => "West", } } // ── action_to_map ───────────────────────────────────────────────────────────── /// Converts an [`Action`] to a Rhai map for `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 { // Dynamic::from(String) avoids rhai 1.x's From<&str> lifetime restriction. let ds = |v: &'static str| Dynamic::from(v.to_string()); let mut m = rhai::Map::new(); match action { Action::Move(dir) => { m.insert("type".into(), ds("Move")); m.insert("dir".into(), ds(dir_to_str(*dir))); } Action::SetTile(n) => { m.insert("type".into(), ds("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(); m.insert("type".into(), ds("Log")); m.insert("msg".into(), Dynamic::from(text)); } Action::SetTag { target, tag, present, } => { m.insert("type".into(), ds("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, dur) => { m.insert("type".into(), ds("Say")); m.insert("msg".into(), Dynamic::from(text.clone())); m.insert("duration".into(), Dynamic::from(*dur)); } Action::Delay(secs) => { m.insert("type".into(), ds("Delay")); m.insert("secs".into(), Dynamic::from(*secs)); } Action::SetColor { fg, bg } => { m.insert("type".into(), ds("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, } => { m.insert("type".into(), ds("Send")); m.insert("target".into(), Dynamic::from(*target as i64)); m.insert("name".into(), Dynamic::from(fn_name.clone())); match arg { SendArg::Int(i) => m.insert("arg".into(), Dynamic::from(*i)), SendArg::Float(f) => m.insert("arg".into(), Dynamic::from(*f)), SendArg::String(s) => m.insert("arg".into(), Dynamic::from(s.clone())), SendArg::None => m.insert("arg".into(), Dynamic::UNIT) }; } // Scroll lines are not inspectable via the queue map API; emit type only. Action::Scroll(_) => { m.insert("type".into(), ds("Scroll")); } Action::Teleport { x, y } => { m.insert("type".into(), ds("Teleport")); m.insert("x".into(), Dynamic::from(*x as i64)); m.insert("y".into(), Dynamic::from(*y as i64)); } Action::Push { x, y, dir } => { m.insert("type".into(), ds("Push")); m.insert("x".into(), Dynamic::from(*x as i64)); m.insert("y".into(), Dynamic::from(*y as i64)); m.insert("dir".into(), ds(dir_to_str(*dir))); } // Shift cells are not inspectable via the queue map API; emit type only. Action::Shift(_) => { m.insert("type".into(), ds("Shift")); } Action::AddGems(n) => { m.insert("type".into(), ds("AddGems")); m.insert("n".into(), Dynamic::from(*n)); } Action::AlterHealth(dh) => { m.insert("type".into(), ds("AlterHealth")); m.insert("dh".into(), Dynamic::from(*dh)); } Action::SetKey(color, present) => { m.insert("type".into(), ds("SetKey")); m.insert("color".into(), Dynamic::from(color.clone())); m.insert("present".into(), Dynamic::from(*present)); } Action::Die => { m.insert("type".into(), ds("Die")); } } m } /// An action promoted from an object's output queue onto the board queue, tagged /// with the object that issued it. pub struct BoardAction { /// The stable [`ObjectId`] of the object that issued the action. pub(crate) source: ObjectId, /// The action to apply. pub(crate) action: Action, }