From 6cd34ebb4eb6a82afb5fcd27ad5707d2239a3b5f Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Fri, 5 Jun 2026 00:12:57 -0500 Subject: [PATCH] removed boardstate --- ARCHITECTURE.md | 8 ++++---- CLAUDE.md | 7 +++---- kiln-core/src/game.rs | 26 +++++++------------------- kiln-core/src/script.rs | 35 ++++++++++++++--------------------- 4 files changed, 28 insertions(+), 48 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e77f971..6030cfd 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. -**`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. +**`GameState`** +Owns the board behind an `Rc>`, alongside the message `log` and a `ScriptHost`. Keeping the board 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,11 @@ An earlier design had `GameMap { board: Board, player: Player, ... }`. This was ### `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 **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`. +`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 become `Error` commands, not fatal. -**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. +**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 `Board`. 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. diff --git a/CLAUDE.md b/CLAUDE.md index 6b59b59..4cc055f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,16 +53,15 @@ 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 -- `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. +- `GameState` — holds `board: Rc>`, the message `log: Vec`, and a `ScriptHost`. The board sits behind `Rc>` as a sibling of the `ScriptHost` so script host functions can hold a shared handle to it without aliasing the borrow that's running the engine. 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 command queue. Built with `ScriptHost::new(&Rc>)` (registers the API, compiles scripts, reports compile/unknown-script failures as `GameCommand::Error`; runs nothing). +- `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`. +- **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 `Board`. - **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. diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 6ba4588..1523a3e 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -393,22 +393,10 @@ impl Board { } } -/// 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. It owns the world ([`BoardState`], behind a shared `Rc`), +/// game data. It owns the board (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 @@ -416,7 +404,7 @@ pub struct BoardState { /// after each script batch. pub struct GameState { /// The scriptable world, shared with the script host's read getters. - board_state: Rc>, + board: 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. @@ -430,10 +418,10 @@ 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 board_state = Rc::new(RefCell::new(BoardState { board })); - let scripts = ScriptHost::new(&board_state); + let board = Rc::new(RefCell::new(board)); + let scripts = ScriptHost::new(&board); let mut state = Self { - board_state, + board, log: Vec::new(), scripts, }; @@ -444,12 +432,12 @@ impl GameState { /// 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) + self.board.borrow() } /// Borrows the active board for mutation. pub fn board_mut(&self) -> RefMut<'_, Board> { - RefMut::map(self.board_state.borrow_mut(), |bs| &mut bs.board) + self.board.borrow_mut() } /// Appends a styled message to the log. diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 3531b24..236de43 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -11,7 +11,7 @@ //! //! 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. +//! 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 @@ -23,7 +23,7 @@ //! //! [`GameState`]: crate::game::GameState -use crate::game::BoardState; +use crate::game::Board; use crate::log::LogLine; use rhai::{AST, CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope}; use std::cell::RefCell; @@ -81,10 +81,10 @@ impl From for (i32, i32) { } /// 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 +/// reference to the [`Board`]; only getters are registered, so it is /// read-only by construction. #[derive(Clone)] -struct BoardView(Rc>); +struct BoardView(Rc>); /// A compiled script plus which lifecycle hooks it defines. struct CompiledScript { @@ -128,15 +128,14 @@ impl ScriptHost { /// 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 { + pub fn new(board_cell: &Rc>) -> Self { let commands: CommandQueue = Rc::new(RefCell::new(Vec::new())); let mut engine = Engine::new(); register_read_api(&mut engine); register_write_api(&mut engine, &commands); - let board_state = state.borrow(); - let board = &board_state.board; + let board = board_cell.borrow(); // Compile each referenced script once; attribute failures to the first // object that references the script. @@ -200,12 +199,12 @@ impl ScriptHost { scripts.contains_key(name).then(|| ObjectRuntime { object_index: i, script_name: name.clone(), - scope: new_object_scope(state), + scope: new_object_scope(board_cell), }) }) .collect(); - drop(board_state); + drop(board); Self { engine, @@ -280,16 +279,10 @@ impl ScriptHost { /// 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 - }); + engine.register_get("player_x", |b: &mut BoardView| b.0.borrow().player.x as i64); + engine.register_get("player_y", |b: &mut BoardView| b.0.borrow().player.y as i64); + engine.register_get("width", |b: &mut BoardView| b.0.borrow().width as i64); + engine.register_get("height", |b: &mut BoardView| b.0.borrow().height as i64); } /// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log` @@ -335,9 +328,9 @@ fn source_of(ctx: &NativeCallContext) -> usize { /// 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> { +fn new_object_scope(board: &Rc>) -> Scope<'static> { let mut scope = Scope::new(); - scope.push_constant("board", BoardView(state.clone())); + scope.push_constant("board", BoardView(board.clone())); scope.push_constant("north", Direction::North); scope.push_constant("south", Direction::South); scope.push_constant("east", Direction::East);