more refactor

This commit is contained in:
2026-06-05 01:09:50 -05:00
parent 6cd34ebb4e
commit 8f2cca2907
5 changed files with 35 additions and 36 deletions
+5 -5
View File
@@ -107,9 +107,9 @@ Owns the board behind an `Rc<RefCell<Board>>`, 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<RefCell<Vec<Command>>>` 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<RefCell<Board>>` (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<RefCell<Vec<Command>>>` 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<Direction> 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<Direction> 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)
+5 -5
View File
@@ -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<RefCell<Board>>)` (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<RefCell<Board>>` (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<RefCell<Vec<Command>>>`, 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<Direction> 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<Direction> 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
+5 -5
View File
@@ -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);
+19 -20
View File
@@ -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<RefCell<Board>>` and borrows it briefly per getter.
//! Scripts **read** the world directly through a read-only `Rc<RefCell<Board>>` (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<Direction> 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<RefCell<Board>>);
type BoardRef = Rc<RefCell<Board>>;
/// 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<RefCell<Board>>) -> 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>("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::<BoardRef>("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<RefCell<Board>>) -> 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
}
+1 -1
View File
@@ -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
}
"""