From 8f2cca29077da71edf19298fa011bdaebc2ccf91 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Fri, 5 Jun 2026 01:09:50 -0500 Subject: [PATCH] more refactor --- ARCHITECTURE.md | 10 +++++----- CLAUDE.md | 10 +++++----- kiln-core/src/game.rs | 10 +++++----- kiln-core/src/script.rs | 39 +++++++++++++++++++-------------------- maps/start.toml | 2 +- 5 files changed, 35 insertions(+), 36 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6030cfd..9ec522d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -107,9 +107,9 @@ Owns the board behind an `Rc>`, alongside the message `log` and a 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 `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. +**Reads vs writes.** Scripts **read** the world directly: read getters are registered on `Rc>` (the `BoardRef` alias, exposed to Rhai as type `Board`) — getters only, so read-only by construction — and a clone of the handle is pushed into each scope as the constant `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. +**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. --- @@ -309,10 +309,10 @@ target_entry = "west_door" [scripts] greeter = """ fn init() { - log(`player at ${board.player_x}, ${board.player_y}`); # read + 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 +fn tick(dt) { move(North); } # dt = elapsed seconds; dir ∈ North/South/East/West """ ``` @@ -336,7 +336,7 @@ 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, **read** the world via `board.*`, and **write** via queued commands (`move`, `set_tile`, `log`). 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 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) diff --git a/CLAUDE.md b/CLAUDE.md index 4cc055f..412e282 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,9 +61,9 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`. **`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). - 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 `Board`. +- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered directly on `Rc>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`, so scripts write `Board.player_x` etc. 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. +- **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. @@ -200,14 +200,14 @@ target_entry = "west_door" [scripts] greeter = """ fn init() { # optional, run once at startup - log(`player at ${board.player_x}, ${board.player_y}`); # read the world + 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 +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; 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)`. +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 1523a3e..ab1f94a 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -663,7 +663,7 @@ mod tests { 3, (3, 1), vec![scripted_object(2, 1, "r")], - &[("r", "fn init() { log(board.player_x.to_string()); }")], + &[("r", "fn init() { log(Board.player_x.to_string()); }")], ); let mut game = GameState::new(board); game.run_init(); @@ -678,7 +678,7 @@ mod tests { 3, (0, 0), vec![scripted_object(2, 1, "m")], - &[("m", "fn init() { move(east); }")], + &[("m", "fn init() { move(East); }")], ); let mut game = GameState::new(board); game.run_init(); @@ -695,7 +695,7 @@ mod tests { 3, (0, 0), vec![scripted_object(0, 1, "m")], - &[("m", "fn init() { move(west); }")], + &[("m", "fn init() { move(West); }")], ); let mut game = GameState::new(board); game.run_init(); @@ -748,8 +748,8 @@ mod tests { (0, 0), vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")], &[ - ("e", "fn init() { move(east); }"), - ("w", "fn init() { move(west); }"), + ("e", "fn init() { move(East); }"), + ("w", "fn init() { move(West); }"), ], ); let mut game = GameState::new(board); diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 236de43..f82ed95 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -9,9 +9,9 @@ //! //! ## 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 **read** the world directly through a read-only `Rc>` (type aliased +//! as [`BoardRef`], registering getters only) pushed into each scope as `Board`, e.g. +//! `Board.player_x`. Functions borrow 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 @@ -19,7 +19,7 @@ //! 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. +//! so scripts write `move(North)` without naming themselves. //! //! [`GameState`]: crate::game::GameState @@ -80,11 +80,10 @@ impl From for (i32, i32) { } } -/// A read-only handle to the world, exposed to scripts as `board`. Holds a shared +/// 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. -#[derive(Clone)] -struct BoardView(Rc>); +type BoardRef = Rc>; /// A compiled script plus which lifecycle hooks it defines. struct CompiledScript { @@ -128,7 +127,7 @@ 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(board_cell: &Rc>) -> Self { + pub fn new(board_cell: &BoardRef) -> Self { let commands: CommandQueue = Rc::new(RefCell::new(Vec::new())); let mut engine = Engine::new(); @@ -275,14 +274,14 @@ impl ScriptHost { } } -/// Registers the read-only `board` API: a [`BoardView`] type with getters that +/// Registers the read-only `Board` API: a [`BoardRef`] 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().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); + 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); } /// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log` @@ -328,12 +327,12 @@ 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(board: &Rc>) -> Scope<'static> { +fn new_object_scope(board: &BoardRef) -> Scope<'static> { let mut scope = Scope::new(); - 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); - scope.push_constant("west", Direction::West); + scope.push_constant("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); scope } diff --git a/maps/start.toml b/maps/start.toml index 3720b1a..b9f78dc 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -50,7 +50,7 @@ script_name = "greeter" [scripts] greeter = """ fn init() { - log(`hello from object — player at ${board.player_x}, ${board.player_y}`); + 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