diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d43eb25..e77f971 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -92,8 +92,8 @@ An earlier design had `cells: Vec<(Glyph, usize)>` where the `usize` indexed a p **Why `Board` is the complete unit (no wrapper struct):** An earlier design had `GameMap { board: Board, player: Player, ... }`. This was eliminated because the split was artificial: there's no meaningful use of a `Board` without a player position, and no meaningful use of a player without a `Board`. ZZT itself treats a board as containing everything — the grid, the objects, and the player entry point. Collapsing to a single struct matches the domain model. -**`GameState`** -Owns the `Board`, the message `log: Vec`, and a `ScriptHost`. It keeps mutation logic (collision checking, movement) separate from the data and is where event processing/scripting dispatch lives: `try_move` for movement, `run_init()` to run object `init()` hooks once at startup, and `tick(dt)` to run object `tick(dt)` hooks every frame. After each script batch it flushes script-emitted log lines into `log`. +**`BoardState` / `GameState`** +`BoardState` is the scriptable world — currently just `{ board: Board }`, with room for runtime-only state (e.g. a shared variable blackboard). `GameState` holds it behind an `Rc>`, alongside the message `log` and a `ScriptHost`. Keeping the world a **sibling** of the script host (rather than owned by it) is the key ownership move: host functions capture a clone of that `Rc` — a `'static` handle to a *different* `RefCell` than the running engine — so they can read/queue writes without aliasing the borrow that's executing scripts. Front-ends and internal logic reach the board only through `board() -> Ref` / `board_mut() -> RefMut` (no `pub board`). `try_move` moves the player; `run_init()` / `tick(dt)` run object hooks; `apply_commands()` drains the script command queue *after* each batch — the deferred apply is when `&mut` is finally free of any outstanding borrow. --- @@ -103,11 +103,13 @@ Owns the `Board`, the message `log: Vec`, and a `ScriptHost`. It keeps ### `script.rs` — Rhai scripting runtime -`ScriptHost` owns the Rhai `Engine`, the scripts referenced by a board's objects (compiled once per name via `Engine::compile`), a persistent per-object `Scope`, and a shared **log sink**. Built with `ScriptHost::new(&board)`, which compiles scripts and records each object's available hooks (detected with `AST::iter_functions()` by name/arity) but **runs nothing** — compile errors and unknown-script references are reported into the log. +`ScriptHost` owns the Rhai `Engine`, the scripts referenced by a board's objects (compiled once per name via `Engine::compile`), a persistent per-object `Scope`, and a shared **command queue**. Built with `ScriptHost::new(&Rc>)`, which registers the API, compiles scripts, and records each object's available hooks (detected with `AST::iter_functions()` by name/arity) but **runs nothing** — compile/unknown-script failures are queued as `GameCommand::Error`. -Two optional lifecycle hooks per object: `init()` (zero-arg, run once) and `tick(dt)` (elapsed seconds as `f64`, run per frame), driven by `run_init()` / `run_tick(dt)`. Runtime errors are logged, not fatal. +Two optional lifecycle hooks per object: `init()` (zero-arg, run once) and `tick(dt)` (elapsed seconds as `f64`, run per frame), driven by `run_init()` / `run_tick(dt)`. Runtime errors become `Error` commands, not fatal. -The one host function so far is `log(s)`, which appends an uncolored `LogLine`. The borrow problem — Rhai's registered closures must be `'static`, yet `log` must reach the game log — is solved by writing into an `Rc>>` sink cloned into the closure; `GameState` drains it with `take_pending()` after each batch, so running scripts never needs a mutable borrow of `GameState`. (Default `call_fn` rewinds the scope per call, so script-local persistence across ticks is future work; the per-object `Scope` is the seam for that and for injecting object state.) `Engine`/`Scope` are not `Send` — fine for the single-threaded kiln-tui. +**Reads vs writes.** Scripts **read** the world directly: a read-only `BoardView` (a registered type with getters only — read-only by construction) is pushed into each scope as `board`, so a script writes `board.player_x`; the getter briefly borrows the shared `BoardState`. Scripts **write** by enqueuing `Command { source, kind }` where `kind: GameCommand` is `Move(Direction)` / `SetTile(u32)` / `Log(LogLine)` / `Error(String)`. The host fns `move`/`set_tile`/`log` push into a shared `Rc>>` cloned into each closure (the same `'static`-closure trick the old log sink used, generalized); `GameState::apply_commands` drains and applies them after the batch. Reads-are-live, writes-are-deferred gives frame-coherent semantics and keeps script execution free of any `&mut GameState` borrow. + +**Sender identity** (which object issued a command) rides Rhai's per-call **tag**: `run` calls `call_fn_with_options(...with_tag(object_index)...)` and the host fns read it via `NativeCallContext::tag()`, so scripts write `move(north)` without a `this` argument (`north`/`south`/`east`/`west` are `Direction` constants in scope; `impl From for (i32,i32)` yields the delta). The object's identity is currently its index into `Board::objects` — a stopgap (a `// TODO` flags replacing it with stable unique ids that survive spawn/destroy). Default `call_fn` rewinds the scope per call, so script-local persistence across ticks is still future work; the per-object `Scope` is the seam for that. `Engine`/`Scope` are not `Send` — fine for the single-threaded kiln-tui. --- @@ -306,8 +308,11 @@ target_entry = "west_door" [scripts] greeter = """ -fn init() { log("hello world"); } -fn tick(dt) { } +fn init() { + log(`player at ${board.player_x}, ${board.player_y}`); # read + set_tile(2); # write +} +fn tick(dt) { move(north); } # dt = elapsed seconds; dir ∈ north/south/east/west """ ``` @@ -331,11 +336,12 @@ A direct mapping from palette character → `(Glyph, Archetype)` means the map f ## What's not yet implemented -**Object scripting** — the runtime exists (`script.rs`): objects run optional `init()`/`tick(dt)` hooks and can call `log(s)`. Still to come: +**Object scripting** — the runtime exists (`script.rs`): objects run optional `init()`/`tick(dt)` hooks, **read** the world via `board.*`, and **write** via queued commands (`move`, `set_tile`, `log`). Still to come: 1. More event hooks beyond lifecycle — e.g. fire `on_touch` when the player moves onto an object's cell -2. Grow the Rhai API surface (movement, board queries, `send_message`, randomness) and give a script a handle to *its own* object -3. Persist script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call) -4. `PortalDef` is still parsed-only — no multi-board navigation yet +2. Grow the command vocabulary and read API (`send_message`, randomness, board queries, more glyph setters) +3. **Stable object ids** — `Command.source` / `ObjectRuntime.object_index` are array indices into `Board::objects`, which break under spawn/destroy/reorder; replace with monotonically-increasing unique ids and key objects by them (a `// TODO` marks this in code) +4. Persist script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call) +5. `PortalDef` is still parsed-only — no multi-board navigation yet **Object behavior from scripts** — `ObjectDef.passable` and `ObjectDef.opaque` are live and editable in the editor, but they are static author-set values. Eventually they should be overridable by the object's Rhai script at runtime (e.g. a door script that flips `passable` when opened). `Board::is_passable` already has the right shape for this; the script runtime just needs to be wired in. diff --git a/CLAUDE.md b/CLAUDE.md index 0b91d6a..6b59b59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,16 +53,20 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`. - `Player` — `x: i32, y: i32` - `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `passable: bool`, `opaque: bool`, `script_name: Option`. `passable` defaults `false` and `opaque` defaults `true` in map files. Its `init()`/`tick(dt)` hooks are run by the scripting runtime (see `script.rs`). - `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired -- `GameState` — holds `board: Board`, the message `log: Vec`, and a `ScriptHost`. `try_move(dx, dy)` checks passability before moving the player; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` runs object `tick(dt)` hooks every frame. Script-emitted log lines are flushed into `log` after each call. +- `BoardState` — the scriptable world: currently just `board: Board` (room to grow, e.g. a shared variable blackboard). Held by `GameState` behind `Rc>` so script host functions can share a handle to it without aliasing the running engine. +- `GameState` — holds `board_state: Rc>`, the message `log: Vec`, and a `ScriptHost`. Front-ends/logic reach the board through `board() -> Ref` / `board_mut() -> RefMut` (there is no `pub board` field). `try_move` moves the player; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` runs object `tick(dt)` hooks every frame. After each script batch, `apply_commands()` drains the script command queue: `Log`/`Error` → `log`, `SetTile` → the source object's glyph, `Move(dir)` → `move_object` (bounds + `is_passable`). This deferred apply (writes after the batch) is what keeps script execution borrow-safe. **`kiln-core/src/log.rs`** — styled log messages: - `LogSpan { text, fg: Option, bg: Option }` and `LogLine { spans: Vec }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, and `append()` build messages; each front-end converts a `LogLine` to its own styled text at render time. **`kiln-core/src/script.rs`** — Rhai scripting runtime: -- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistent `Scope`, and a shared log sink. Built with `ScriptHost::new(&board)` (compiles scripts, reports compile/unknown-script errors to the log; runs nothing). -- Lifecycle hooks per object: `init()` (zero-arg) and `tick(dt)` (elapsed seconds as `f64`), both optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)`; runtime errors are logged, not fatal. -- Host function: `log(s)` appends an uncolored `LogLine` to the shared sink, drained by `GameState` via `take_pending()`. The sink (an `Rc>>`) is how scripts write to the log without a mutable borrow of `GameState`. +- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistent `Scope`, and a shared command queue. Built with `ScriptHost::new(&Rc>)` (registers the API, compiles scripts, reports compile/unknown-script failures as `GameCommand::Error`; runs nothing). +- Lifecycle hooks per object: `init()` (zero-arg) and `tick(dt)` (elapsed seconds as `f64`), both optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)`; runtime errors become `Error` commands, not fatal. +- **Reads (direct):** scripts read through `BoardView` — a read-only handle (getters only, so read-only by construction) pushed into each scope as `board`, e.g. `board.player_x`, `board.player_y`, `board.width`, `board.height`. Each getter briefly borrows the shared `BoardState`. +- **Writes (command queue):** host fns `move(dir)`, `set_tile(n)`, `log(s)` push a `Command { source, kind: GameCommand }` (`GameCommand` = `Move(Direction)` / `SetTile(u32)` / `Log(LogLine)` / `Error(String)`) into a shared `Rc>>`, drained by `GameState::take_commands` and applied after the batch. This is how scripts mutate without a `&mut GameState` borrow. +- **Sender identity:** `source` (which object issued a command) rides the per-call **tag** — `run` calls `call_fn_with_options(...with_tag(object_index)...)` and the host fns read it via `NativeCallContext::tag()`. Scripts write `move(north)` without naming themselves; `north`/`south`/`east`/`west` are `Direction` constants in scope, and `impl From for (i32,i32)` gives the delta. - `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui. +- **TODO in-code:** `Command.source` (and `ObjectRuntime.object_index`) are array indices into `Board::objects` — a stopgap that breaks under object spawn/destroy/reorder; should become a monotonically-increasing unique object id with objects keyed by it. **`kiln-core/src/map_file.rs`** — map file loading: - `MapFile` and friends — serde `Deserialize` types for TOML map files @@ -196,12 +200,15 @@ target_entry = "west_door" # Named Rhai scripts, referenced by objects via `script_name`. [scripts] greeter = """ -fn init() { log("hello world"); } # optional, run once at startup -fn tick(dt) { } # optional, run every frame with elapsed seconds +fn init() { # optional, run once at startup + log(`player at ${board.player_x}, ${board.player_y}`); # read the world + set_tile(2); # write: change my glyph +} +fn tick(dt) { move(north); } # optional, run every frame; dt = elapsed seconds """ ``` -Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`/`tick(dt)` functions and call the host function `log(s)`. +Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`/`tick(dt)` functions; they **read** the world through `board.*` (e.g. `board.player_x`) and **write** via host functions `move(dir)` (`dir` ∈ `north`/`south`/`east`/`west`), `set_tile(n)`, and `log(s)`. ### Key design decisions diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 4160c94..6ba4588 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -1,9 +1,11 @@ use crate::log::LogLine; -use crate::script::ScriptHost; +use crate::script::{Direction, GameCommand, ScriptHost}; use color::Rgba8; use serde::{Deserialize, Serialize}; +use std::cell::{Ref, RefCell, RefMut}; use std::collections::HashMap; use std::hash::{Hash, Hasher}; +use std::rc::Rc; use std::time::Duration; /// The visual representation of a single board cell. @@ -43,6 +45,7 @@ impl Glyph { /// This is the only hardcoded glyph; all other glyphs come from the map /// file palette. It will be removed once the player becomes a scripted /// object with its own palette entry. + #[rustfmt::skip] pub const fn player() -> Self { Self { tile: 64, @@ -145,6 +148,7 @@ impl Archetype { /// /// This glyph is used only for new cells created in the editor; existing /// cells retain their own per-cell glyph. + #[rustfmt::skip] pub fn default_glyph(&self) -> Glyph { match self { Archetype::Empty => Glyph { @@ -229,6 +233,7 @@ pub struct ObjectDef { impl ObjectDef { /// Returns the default glyph for a newly placed object: tile 63 (`?`) in yellow on black. + #[rustfmt::skip] pub fn default_glyph() -> Glyph { Glyph { tile: 63, @@ -368,7 +373,9 @@ impl Board { /// Panics if `x` or `y` are out of bounds. pub fn is_passable(&self, x: usize, y: usize) -> bool { // An object on this cell blocks if it's unpassable - if let Some(obj) = self.object_at(x, y) && !obj.passable { + if let Some(obj) = self.object_at(x, y) + && !obj.passable + { false } else { self.get(x, y).1.behavior().passable @@ -386,16 +393,30 @@ impl Board { } } -/// Holds the active game board and provides game-logic operations. +/// The scriptable world: the current [`Board`] plus (in the future) runtime-only +/// state such as a shared variable blackboard. +/// +/// `BoardState` is a sibling of the [`ScriptHost`] inside [`GameState`] and is +/// held behind an `Rc>`. Keeping it separate from the script engine is +/// what lets host functions hold a shared handle to the world without aliasing the +/// borrow that is currently running the engine. +pub struct BoardState { + /// The active game board. + pub board: Board, +} + +/// Holds the active game world and provides game-logic operations. /// /// `GameState` is the boundary between the engine (rendering, input) and the -/// game data ([`Board`]). It owns the current board, the message log, and the -/// [`ScriptHost`] driving the board's object scripts, and exposes methods for -/// actions that involve game rules: player movement, the per-frame -/// [`tick`](GameState::tick), and the one-time [`run_init`](GameState::run_init). +/// game data. It owns the world ([`BoardState`], behind a shared `Rc`), +/// the message log, and the [`ScriptHost`] driving object scripts. Front-ends and +/// internal logic reach the board through [`board`](GameState::board) / +/// [`board_mut`](GameState::board_mut). Scripts read the board directly and +/// request mutations as commands, applied by [`apply_commands`](GameState::apply_commands) +/// after each script batch. pub struct GameState { - /// The currently active board. - pub board: Board, + /// The scriptable world, shared with the script host's read getters. + board_state: Rc>, /// The in-game message log, oldest first (newest pushed at the end). pub log: Vec, /// The Rhai scripting runtime driving this board's scripted objects. @@ -409,42 +430,89 @@ impl GameState { /// call [`GameState::run_init`] once the game is ready to start. Any script /// compile errors are surfaced into the log here. pub fn new(board: Board) -> Self { - let scripts = ScriptHost::new(&board); + let board_state = Rc::new(RefCell::new(BoardState { board })); + let scripts = ScriptHost::new(&board_state); let mut state = Self { - board, + board_state, log: Vec::new(), scripts, }; // Surface any compile-time script errors collected during ScriptHost::new. - state.flush_script_log(); + state.apply_commands(); state } + /// Borrows the active board for reading (e.g. by a front-end renderer). + pub fn board(&self) -> Ref<'_, Board> { + Ref::map(self.board_state.borrow(), |bs| &bs.board) + } + + /// Borrows the active board for mutation. + pub fn board_mut(&self) -> RefMut<'_, Board> { + RefMut::map(self.board_state.borrow_mut(), |bs| &mut bs.board) + } + /// Appends a styled message to the log. pub fn log(&mut self, line: LogLine) { self.log.push(line); } - /// Runs the `init()` hook of every scripted object. Call once, after the - /// whole map is loaded and the game is about to start — never during map - /// deserialization, since a script may inspect the board. + /// Runs the `init()` hook of every scripted object, then applies the commands + /// they queued. Call once, after the whole map is loaded and the game is about + /// to start — never during map deserialization, since a script may inspect the + /// board. pub fn run_init(&mut self) { self.scripts.run_init(); - self.flush_script_log(); + self.apply_commands(); } /// Advances real-time game state by `dt` (the elapsed time since the last /// tick). Called once per frame by the front-end's game loop; drives every - /// scripted object's `tick(dt)` hook. + /// scripted object's `tick(dt)` hook, then applies the commands they queued. pub fn tick(&mut self, dt: Duration) { self.scripts.run_tick(dt.as_secs_f64()); - self.flush_script_log(); + self.apply_commands(); } - /// Moves any messages queued by scripts (via the `log` host function) into - /// the real game log. - fn flush_script_log(&mut self) { - self.log.extend(self.scripts.take_pending()); + /// Drains the script command queue and applies each command. Runs *after* a + /// script batch, when nothing holds a borrow of the board — so the mutations + /// here can't conflict with the read getters scripts use during execution. + fn apply_commands(&mut self) { + for cmd in self.scripts.take_commands() { + match cmd.kind { + GameCommand::Log(line) => self.log.push(line), + // TODO: errors are only logged for now. This is the place to halt + // execution / set an error state when a script faults. + GameCommand::Error(msg) => self.log.push(LogLine::raw(msg)), + GameCommand::SetTile(tile) => { + if let Some(obj) = self.board_mut().objects.get_mut(cmd.source) { + obj.glyph.tile = tile; + } + } + GameCommand::Move(dir) => self.move_object(cmd.source, dir), + } + } + } + + /// Moves object `idx` one cell in `dir`, if the target is in bounds and + /// passable. This is where movement rules live (a no-op when blocked). + fn move_object(&mut self, idx: usize, dir: Direction) { + let (dx, dy): (i32, i32) = dir.into(); + let mut board = self.board_mut(); + let Some((ox, oy)) = board.objects.get(idx).map(|o| (o.x, o.y)) else { + return; + }; + let nx = ox as i32 + dx; + let ny = oy as i32 + dy; + if nx < 0 || ny < 0 { + return; + } + let (nx, ny) = (nx as usize, ny as usize); + if nx < board.width && ny < board.height && board.is_passable(nx, ny) { + let obj = &mut board.objects[idx]; + obj.x = nx; + obj.y = ny; + } } /// Attempts to move the player by `(dx, dy)` cells. @@ -452,14 +520,15 @@ impl GameState { /// The move is ignored if the target cell is out of bounds or its behavior /// is not passable. No-ops silently (the caller does not need to check). pub fn try_move(&mut self, dx: i32, dy: i32) { - let nx = self.board.player.x + dx; - let ny = self.board.player.y + dy; + let mut board = self.board_mut(); + let nx = board.player.x + dx; + let ny = board.player.y + dy; if nx >= 0 && ny >= 0 { let nx = nx as usize; let ny = ny as usize; - if nx < self.board.width && ny < self.board.height && self.board.is_passable(nx, ny) { - self.board.player.x = nx as i32; - self.board.player.y = ny as i32; + if nx < board.width && ny < board.height && board.is_passable(nx, ny) { + board.player.x = nx as i32; + board.player.y = ny as i32; } } } @@ -492,6 +561,44 @@ mod tests { } } + /// An `ObjectDef` at `(x, y)` bound to the named script. + fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef { + let mut o = ObjectDef::new(x, y); + o.script_name = Some(script.to_string()); + o + } + + /// Builds an all-empty (passable) `w×h` board with the given player position, + /// objects, and `(name, source)` scripts — room for movement, unlike the 1×1 + /// `board_with_object`. + fn open_board( + w: usize, + h: usize, + player: (i32, i32), + objects: Vec, + scripts: &[(&str, &str)], + ) -> Board { + Board { + name: "test".into(), + width: w, + height: h, + cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h], + player: Player { + x: player.0, + y: player.1, + }, + objects, + portals: Vec::new(), + font: None, + zoom: 1, + scripts: scripts + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + board_script_name: None, + } + } + /// Flattens each log line into a single string for easy assertions. fn log_texts(game: &GameState) -> Vec { game.log @@ -502,7 +609,10 @@ mod tests { #[test] fn init_runs_only_on_run_init_not_at_construction() { - let board = board_with_object(Some("greet"), &[("greet", r#"fn init() { log("hello"); }"#)]); + let board = board_with_object( + Some("greet"), + &[("greet", r#"fn init() { log("hello"); }"#)], + ); let mut game = GameState::new(board); // init must not fire during construction / deserialization. assert!(game.log.is_empty()); @@ -538,8 +648,10 @@ mod tests { assert!(game.log.is_empty()); // Script defines neither init nor tick: also a no-op. - let mut game = - GameState::new(board_with_object(Some("e"), &[("e", "fn other() { log(\"x\"); }")])); + let mut game = GameState::new(board_with_object( + Some("e"), + &[("e", "fn other() { log(\"x\"); }")], + )); game.run_init(); game.tick(Duration::from_millis(33)); assert!(game.log.is_empty()); @@ -555,4 +667,107 @@ mod tests { let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")])); assert!(log_texts(&game)[0].contains("failed to compile")); } + + #[test] + fn script_reads_board_through_view() { + let board = open_board( + 5, + 3, + (3, 1), + vec![scripted_object(2, 1, "r")], + &[("r", "fn init() { log(board.player_x.to_string()); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + // The view reported the player's x (3). + assert_eq!(log_texts(&game), vec!["3"]); + } + + #[test] + fn move_command_relocates_the_source_object() { + let board = open_board( + 5, + 3, + (0, 0), + vec![scripted_object(2, 1, "m")], + &[("m", "fn init() { move(east); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + let b = game.board(); + // East increments x by one; the object started at (2, 1). + assert_eq!((b.objects[0].x, b.objects[0].y), (3, 1)); + } + + #[test] + fn move_into_a_wall_or_edge_is_a_noop() { + // Object at the west edge moving west: out of bounds, ignored. + let board = open_board( + 5, + 3, + (0, 0), + vec![scripted_object(0, 1, "m")], + &[("m", "fn init() { move(west); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(game.board().objects[0].x, 0); + } + + #[test] + fn set_tile_command_changes_the_source_glyph() { + let board = open_board( + 5, + 3, + (0, 0), + vec![scripted_object(2, 1, "s")], + &[("s", "fn init() { set_tile(7); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(game.board().objects[0].glyph.tile, 7); + } + + #[test] + fn start_map_greeter_runs_init() { + // End-to-end against the shipped example map: load it, run init, and + // confirm the greeter read the board (interpolated message) and wrote to + // itself (set_tile). Also guards the example from drifting out of sync. + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml"); + let board = crate::map_file::load(path).expect("load start.toml"); + let mut game = GameState::new(board); + game.run_init(); + assert!( + log_texts(&game) + .iter() + .any(|t| t.contains("hello from object")), + "greeter init should log a greeting" + ); + assert!( + game.board().objects.iter().any(|o| o.glyph.tile == 2), + "greeter set_tile(2) should change its glyph" + ); + } + + #[test] + fn commands_are_routed_to_their_own_source_object() { + // Two objects with different scripts move in opposite directions; each + // must affect only itself (the per-call tag routes the command source). + // Targets are kept apart so the impassable objects don't block each other. + let board = open_board( + 5, + 3, + (0, 0), + vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")], + &[ + ("e", "fn init() { move(east); }"), + ("w", "fn init() { move(west); }"), + ], + ); + let mut game = GameState::new(board); + game.run_init(); + let b = game.board(); + assert_eq!(b.objects[0].x, 1); // moved east from 0 + assert_eq!(b.objects[1].x, 3); // moved west from 4 + } } diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index c0233a7..dd102a9 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -152,16 +152,27 @@ pub(crate) struct MapFileObjectEntry { script_name: Option, } -fn default_true() -> bool { true } -fn default_zoom() -> u32 { 1 } -fn is_zoom_default(z: &u32) -> bool { *z == 1 } +fn default_true() -> bool { + true +} +fn default_zoom() -> u32 { + 1 +} +fn is_zoom_default(z: &u32) -> bool { + *z == 1 +} /// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`]. /// Returns opaque black on any parse failure. fn parse_color(hex: &str) -> Rgba8 { let hex = hex.trim_start_matches('#'); if hex.len() != 6 { - return Rgba8 { r: 0, g: 0, b: 0, a: 255 }; + return Rgba8 { + r: 0, + g: 0, + b: 0, + a: 255, + }; } let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); @@ -475,8 +486,14 @@ content = """ assert!(result.is_err()); // Use .err().unwrap() instead of .unwrap_err() to avoid requiring Board: Debug. let msg = result.err().unwrap(); - assert!(msg.contains("2 rows"), "expected row count in error, got: {msg}"); - assert!(msg.contains("height = 3"), "expected declared height in error, got: {msg}"); + assert!( + msg.contains("2 rows"), + "expected row count in error, got: {msg}" + ); + assert!( + msg.contains("height = 3"), + "expected declared height in error, got: {msg}" + ); } #[test] @@ -487,8 +504,14 @@ content = """ let result = Board::try_from(mf); assert!(result.is_err()); let msg = result.err().unwrap(); - assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}"); - assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}"); + assert!( + msg.contains("3 characters"), + "expected col count in error, got: {msg}" + ); + assert!( + msg.contains("width = 4"), + "expected declared width in error, got: {msg}" + ); } #[test] @@ -549,8 +572,24 @@ bg = "#000000" assert_eq!(obj.x, 1); assert_eq!(obj.y, 1); assert_eq!(obj.glyph.tile, 64); - assert_eq!(obj.glyph.fg, Rgba8 { r: 0x00, g: 0xFF, b: 0xFF, a: 255 }); - assert_eq!(obj.glyph.bg, Rgba8 { r: 0, g: 0, b: 0, a: 255 }); + assert_eq!( + obj.glyph.fg, + Rgba8 { + r: 0x00, + g: 0xFF, + b: 0xFF, + a: 255 + } + ); + assert_eq!( + obj.glyph.bg, + Rgba8 { + r: 0, + g: 0, + b: 0, + a: 255 + } + ); // Save back to TOML and reload; glyph must survive. let map_file = MapFile::from(&board); diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 1aa8ce4..3531b24 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -7,21 +7,84 @@ //! - `init()` — run once after the whole map is loaded (see [`ScriptHost::run_init`]). //! - `tick(dt)` — run every frame with the elapsed seconds (see [`ScriptHost::run_tick`]). //! -//! Scripts have one host function for now: `log(s)`, which appends `s` to the -//! game log. Because Rhai's registered closures must be `'static`, `log` writes -//! into a shared [`LogSink`] that [`ScriptHost::take_pending`] drains; the caller -//! ([`crate::game::GameState`]) then moves those lines into the real log. This -//! keeps script execution from needing a mutable borrow of the game state. +//! ## Reads vs. writes +//! +//! Scripts **read** the world directly through a read-only [`BoardView`] (getters +//! only) pushed into each scope as `board`, e.g. `board.player_x`. The view holds +//! an `Rc>` and borrows it briefly per getter. +//! +//! Scripts **write** by enqueuing [`Command`]s: host functions (`move`, +//! `set_tile`, `log`) push into a shared queue that [`GameState`] drains and +//! applies *after* each batch ([`ScriptHost::take_commands`]). This keeps script +//! execution from ever needing a mutable borrow of the game state, and makes +//! structural/ordering effects deterministic. Which object issued a command is +//! read from the per-call **tag** (set to the object index in [`ScriptHost::run`]), +//! so scripts write `move(north)` without naming themselves. +//! +//! [`GameState`]: crate::game::GameState -use crate::game::Board; +use crate::game::BoardState; use crate::log::LogLine; -use rhai::{Engine, FuncArgs, ImmutableString, Scope, AST}; +use rhai::{AST, CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope}; use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; -/// Shared buffer the Rhai `log` function appends to, drained into the game log. -type LogSink = Rc>>; +/// Shared queue the write host functions push into; drained by [`ScriptHost::take_commands`]. +type CommandQueue = Rc>>; + +/// A mutation requested by a script, applied by [`crate::game::GameState`] after +/// the script batch finishes. +pub struct Command { + /// The object that issued the command. + // TODO: `source` is an index into `Board::objects`, which is a stopgap — it + // breaks under object spawn/destroy/reorder. Replace with a monotonically + // increasing unique object id, with objects stored keyed by it (e.g. + // `BTreeMap`). Mirrored on `ObjectRuntime::object_index`. + pub source: usize, + /// What the command does. + pub kind: GameCommand, +} + +/// The kinds of mutation a script can request. +pub enum GameCommand { + /// 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), + /// A script/engine failure. Applying it logs the message for now; kept a + /// distinct variant so execution can later be halted on error. + Error(String), +} + +/// A cardinal movement direction. +#[derive(Clone, Copy)] +pub enum Direction { + North, + South, + East, + West, +} + +impl From for (i32, i32) { + /// The `(dx, dy)` cell delta for a direction (screen coordinates: +y down). + fn from(d: Direction) -> Self { + match d { + Direction::North => (0, -1), + Direction::South => (0, 1), + Direction::East => (1, 0), + Direction::West => (-1, 0), + } + } +} + +/// A read-only handle to the world, exposed to scripts as `board`. Holds a shared +/// reference to the [`BoardState`]; only getters are registered, so it is +/// read-only by construction. +#[derive(Clone)] +struct BoardView(Rc>); /// A compiled script plus which lifecycle hooks it defines. struct CompiledScript { @@ -36,49 +99,54 @@ struct CompiledScript { /// The runtime state of one scripted object: which script it runs and its own /// persistent Rhai scope. struct ObjectRuntime { - /// Index into [`Board::objects`]. Unused for now; kept as the seam for - /// giving scripts access to their own object. - #[allow(dead_code)] + /// Index into [`crate::game::Board::objects`]; passed to scripts as the + /// per-call tag so host functions know which object issued a command. + // TODO: see [`Command::source`] — array indices should become stable ids. object_index: usize, /// Key into [`ScriptHost::scripts`] naming this object's compiled script. script_name: String, - /// Per-object scope, so injected/object-local state can persist across calls. + /// Per-object scope (holds the `board` view + direction constants, and can + /// hold object-local state in the future). Persists across calls. scope: Scope<'static>, } /// Owns the Rhai engine and per-object script state for a board. pub struct ScriptHost { - /// The Rhai engine, with host functions (`log`) registered. + /// 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, - /// Buffer the `log` host function writes to; drained by [`Self::take_pending`]. - log_sink: LogSink, + /// Queue the write host functions push into; drained by [`Self::take_commands`]. + commands: CommandQueue, } impl ScriptHost { - /// Builds a host for `board`: registers host functions, compiles every script - /// referenced by an object (once each), and creates a fresh scope per scripted - /// object. Compile errors and references to unknown scripts are reported as - /// log lines (retrievable via [`Self::take_pending`]); no script is run here. - pub fn new(board: &Board) -> Self { - let log_sink: LogSink = Rc::new(RefCell::new(Vec::new())); + /// Builds a host for the board behind `state`: registers the read/write API, + /// compiles every script referenced by an object (once each), and creates a + /// fresh scope per scripted object. Compile errors and references to unknown + /// scripts are queued as [`GameCommand::Error`] (retrievable via + /// [`Self::take_commands`]); no script is run here. + pub fn new(state: &Rc>) -> Self { + let commands: CommandQueue = Rc::new(RefCell::new(Vec::new())); let mut engine = Engine::new(); - // `log(s)` — append a plain (uncolored) message to the game log. - { - let sink = log_sink.clone(); - engine.register_fn("log", move |msg: ImmutableString| { - sink.borrow_mut().push(LogLine::raw(msg.to_string())); - }); - } + register_read_api(&mut engine); + register_write_api(&mut engine, &commands); - // Compile each script that an object actually references, once. + let board_state = state.borrow(); + let board = &board_state.board; + + // Compile each referenced script once; attribute failures to the first + // object that references the script. let mut scripts: HashMap = HashMap::new(); - for name in board.objects.iter().filter_map(|o| o.script_name.as_ref()) { - if scripts.contains_key(name) { + let mut failed: HashSet = HashSet::new(); + for (i, obj) in board.objects.iter().enumerate() { + let Some(name) = obj.script_name.as_ref() else { + continue; + }; + if scripts.contains_key(name) || failed.contains(name) { continue; } match board.scripts.get(name) { @@ -100,13 +168,25 @@ impl ScriptHost { }, ); } - Err(err) => log_sink.borrow_mut().push(LogLine::raw(format!( - "script '{name}' failed to compile: {err}" - ))), + Err(err) => { + failed.insert(name.clone()); + commands.borrow_mut().push(Command { + source: i, + kind: GameCommand::Error(format!( + "script '{name}' failed to compile: {err}" + )), + }); + } }, - None => log_sink - .borrow_mut() - .push(LogLine::raw(format!("object references unknown script '{name}'"))), + None => { + failed.insert(name.clone()); + commands.borrow_mut().push(Command { + source: i, + kind: GameCommand::Error(format!( + "object references unknown script '{name}'" + )), + }); + } } } @@ -115,21 +195,23 @@ impl ScriptHost { .objects .iter() .enumerate() - .filter_map(|(i, o)| { - let name = o.script_name.as_ref()?; + .filter_map(|(i, obj)| { + let name = obj.script_name.as_ref()?; scripts.contains_key(name).then(|| ObjectRuntime { object_index: i, script_name: name.clone(), - scope: Scope::new(), + scope: new_object_scope(state), }) }) .collect(); + drop(board_state); + Self { engine, scripts, objects, - log_sink, + commands, } } @@ -144,14 +226,15 @@ impl ScriptHost { self.run("tick", |c| c.has_tick, (dt,)); } - /// Removes and returns the messages queued by `log` since the last drain. - pub fn take_pending(&mut self) -> Vec { - std::mem::take(&mut *self.log_sink.borrow_mut()) + /// Removes and returns the commands queued by scripts since the last drain. + pub fn take_commands(&mut self) -> Vec { + std::mem::take(&mut self.commands.borrow_mut()) } /// Shared driver for the lifecycle hooks: for each object whose script - /// `defined` reports the hook, call it with `args`. Runtime errors are - /// captured into the log sink rather than aborting the batch. + /// `defined` reports the hook, call it with `args`, tagging the call with the + /// object index so host functions know the command source. Runtime errors are + /// captured as [`GameCommand::Error`] rather than aborting the batch. fn run( &mut self, hook: &str, @@ -163,7 +246,7 @@ impl ScriptHost { engine, scripts, objects, - log_sink, + commands, } = self; for obj in objects.iter_mut() { let Some(compiled) = scripts.get(&obj.script_name) else { @@ -172,12 +255,92 @@ impl ScriptHost { if !defined(compiled) { continue; } - if let Err(err) = engine.call_fn::<()>(&mut obj.scope, &compiled.ast, hook, args) { - log_sink.borrow_mut().push(LogLine::raw(format!( - "script '{}' {hook} error: {err}", - obj.script_name - ))); + // The tag carries this object's index to the host functions. + let options = CallFnOptions::default().with_tag(obj.object_index as i64); + if let Err(err) = engine.call_fn_with_options::<()>( + options, + &mut obj.scope, + &compiled.ast, + hook, + args, + ) { + commands.borrow_mut().push(Command { + source: obj.object_index, + kind: GameCommand::Error(format!( + "script '{}' {hook} error: {err}", + obj.script_name + )), + }); } } } } + +/// Registers the read-only `board` API: a [`BoardView`] type with getters that +/// borrow the shared state briefly. +fn register_read_api(engine: &mut Engine) { + engine.register_type_with_name::("BoardView"); + engine.register_get("player_x", |b: &mut BoardView| { + b.0.borrow().board.player.x as i64 + }); + engine.register_get("player_y", |b: &mut BoardView| { + b.0.borrow().board.player.y as i64 + }); + engine.register_get("width", |b: &mut BoardView| b.0.borrow().board.width as i64); + engine.register_get("height", |b: &mut BoardView| { + b.0.borrow().board.height as i64 + }); +} + +/// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log` +/// host functions, each enqueuing a [`Command`] tagged with its source object. +fn register_write_api(engine: &mut Engine, commands: &CommandQueue) { + engine.register_type_with_name::("Direction"); + + let q = commands.clone(); + engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { + q.borrow_mut().push(Command { + source: source_of(&ctx), + kind: GameCommand::Move(dir), + }); + }); + + let q = commands.clone(); + engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: i64| { + q.borrow_mut().push(Command { + source: source_of(&ctx), + kind: GameCommand::SetTile(tile as u32), + }); + }); + + let q = commands.clone(); + engine.register_fn( + "log", + move |ctx: NativeCallContext, msg: ImmutableString| { + q.borrow_mut().push(Command { + source: source_of(&ctx), + kind: GameCommand::Log(LogLine::raw(msg.to_string())), + }); + }, + ); +} + +/// Reads the issuing object's index from the call tag (set in [`ScriptHost::run`]). +fn source_of(ctx: &NativeCallContext) -> usize { + ctx.tag() + .and_then(|t| t.as_int().ok()) + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(0) +} + +/// Builds a fresh per-object scope, seeded with the read-only `board` view and +/// the four direction constants (`north`/`south`/`east`/`west`). +fn new_object_scope(state: &Rc>) -> Scope<'static> { + let mut scope = Scope::new(); + scope.push_constant("board", BoardView(state.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 +} diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 5d539bf..a16c81c 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -21,13 +21,13 @@ use ratatui::crossterm::event::{ use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Spacing}; use ratatui::style::{Color, Style}; +use ratatui::symbols::merge::MergeStrategy; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Wrap}; use render::{BoardWidget, logline_to_line}; use std::io; use std::process::ExitCode; use std::time::{Duration, Instant}; -use ratatui::symbols::merge::MergeStrategy; use term::TerminalCaps; /// View-only UI state for the log panel. This is presentation state, so it lives @@ -197,7 +197,9 @@ fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) { /// panel across the bottom. When the panel is closed the latest log message is /// previewed in the board's bottom-left border after a `[l]og:` label. fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) { - let board = &game.board; + // Borrow the board for the duration of the frame (no script runs during draw). + let board = game.board(); + let board = &*board; if ui.log_open { // Split the screen: board on top, log panel of `log_height` at the bottom. @@ -206,14 +208,19 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) { .spacing(Spacing::Overlap(1)) .areas(frame.area()); - let block = Block::bordered().title(format!(" {} ", board.name)).merge_borders(MergeStrategy::Exact); + let block = Block::bordered() + .title(format!(" {} ", board.name)) + .merge_borders(MergeStrategy::Exact); let inner = block.inner(board_area); frame.render_widget(block, board_area); frame.render_widget(BoardWidget::new(board), inner); // Newest message on top: reverse the log into ratatui lines. let log_block = Block::bordered() - .title(Span::styled(" [l]og: ", Style::default().fg(Color::DarkGray))) + .title(Span::styled( + " [l]og: ", + Style::default().fg(Color::DarkGray), + )) .merge_borders(MergeStrategy::Exact); let lines: Vec = game.log.iter().rev().map(logline_to_line).collect(); let paragraph = Paragraph::new(lines) @@ -223,8 +230,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) { frame.render_widget(paragraph, log_area); } else { // Panel closed: board fills the screen, latest message in the border. - let mut block = - Block::bordered().title(format!(" {} ", board.name)); + let mut block = Block::bordered().title(format!(" {} ", board.name)); block = block.title_bottom(log_preview(game).left_aligned()); let inner = block.inner(frame.area()); frame.render_widget(block, frame.area()); diff --git a/kiln-tui/src/term.rs b/kiln-tui/src/term.rs index b99c32f..3530f60 100644 --- a/kiln-tui/src/term.rs +++ b/kiln-tui/src/term.rs @@ -6,11 +6,11 @@ //! available. The detected [`TerminalCaps`] are intended to be handed to scripts //! so they can pick key bindings that actually work on this terminal. +use color::Rgba8; +use kiln_core::log::LogLine; use ratatui::crossterm::event::{ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; -use color::Rgba8; -use kiln_core::log::LogLine; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::supports_keyboard_enhancement; use std::io; diff --git a/maps/start.toml b/maps/start.toml index ce51ffb..3720b1a 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -50,6 +50,7 @@ script_name = "greeter" [scripts] greeter = """ fn init() { - log("hello world"); + log(`hello from object — player at ${board.player_x}, ${board.player_y}`); + set_tile(2); // change my glyph to ☻ — proves the write path } """ \ No newline at end of file