From a5f60e22e1a0f6081bd6f62fdd527e0095680e5b Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sat, 6 Jun 2026 17:36:00 -0500 Subject: [PATCH] more scripting, objects moving --- CLAUDE.md | 38 +-- kiln-core/src/game.rs | 457 ++++++++++++++++++++++++++++++----- kiln-core/src/map_file.rs | 11 + kiln-core/src/script.rs | 488 +++++++++++++++++++++++++------------- maps/start.toml | 27 +++ 5 files changed, 781 insertions(+), 240 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 877555a..a6cf908 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,26 +51,27 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`. - `Glyph` (`Copy`, `Eq`, `Hash`) — per-cell visual: `tile: u32` (tilesheet index), `fg/bg: Color32`. `Glyph::player()` is a `const fn`; all other glyphs come from the map file. Derives `Hash` so boards can deduplicate glyphs into a palette. - `FontSpec` — optional per-board bitmap font override: `path: String`, `tile_w: u32`, `tile_h: u32`. When `None`, the app default font is used. - `Behavior` — plain data struct of runtime behavioral properties: `solid: bool` (blocks/participates in movement — inverse of the old `passable`), `opaque: bool`, `pushable: Pushable` (an enum `No`/`Any`/`Horizontal`/`Vertical` with `allows(dir)`; only meaningful for `solid` things). Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere. (`ObjectDef.pushable` is still a plain `bool` = any direction.) -- `Solid<'a>` — the single solid occupant of a cell, returned by `Board::solid_at`: `Cell(Archetype)` (a solid grid archetype like a wall) or `Object(&ObjectDef)` (a solid object). At most one solid may occupy a cell — enforced at load time. +- `Solid<'a>` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player` (the player; pushable any direction), `Cell(Archetype)` (a solid grid archetype like a wall) or `Object(&ObjectDef)` (a solid object). At most one solid may occupy a cell — enforced at load time. The player is checked first, so its cell reports `Solid::Player` (and is impassable to movers). - `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `Crate` is solid and pushable any direction (CP437 ■, char 254, light gray on black); `HCrate`/`VCrate` are crates pushable only east/west (↔, char 29) / north/south (↕, char 18) respectively, same colors. `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red. - `ALL_ARCHETYPES: &[Archetype]` — ordered list of valid editor choices (excludes `ErrorBlock`). - `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec`, `portals: Vec`, `font: Option`. `cells` is `pub(crate)`. `solid_at(x, y) -> Option` returns the cell's single solid occupant (object checked before grid archetype); `is_passable(x, y)` is the convenience inverse (`solid_at(...).is_none()`). `in_bounds((i32, i32))` bounds-checks a (possibly negative) coordinate. Push support is split into the read-only `can_push(x, y, dir)` (does the chain of pushable solids starting here end at open space?) and the mutating `push(x, y, dir)` (shoves that chain one cell, leaving `Empty` behind); the read-only half lets a mover answer "can I move here?" via `is_passable || can_push` before committing. `is_valid()` / `load_errors()` expose nonfatal problems collected while loading (a non-serialized `Vec`); `report_error(msg)` appends a red-on-black line and `is_valid()` is just "no load errors". - `Player` — `x: i32, y: i32` -- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `solid: bool`, `opaque: bool`, `pushable: bool`, `script_name: Option`. `solid` and `opaque` default `true`, `pushable` defaults `false` in map files. Its `init()`/`tick(dt)` hooks are run by the scripting runtime (see `script.rs`). +- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `solid: bool`, `opaque: bool`, `pushable: bool`, `script_name: Option`. `solid` and `opaque` default `true`, `pushable` defaults `false` in map files. Its `init()`/`tick(dt)`/`bump(id)` 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: 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(dir: Direction)` moves the player (pushing a crate/solid out of the way if possible); `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` (which gates on `in_bounds` then `is_passable || can_push`, pushing before it relocates). 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(dir: Direction)` moves the player (pushing a crate/solid out of the way if possible) and fires `bump(-1)` on a solid object it walks into; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` advances each object's cooldown then runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue (`take_board_queue()`) and applies each `Action`: `Log` → `log`, `SetTile` → the source object's glyph, `Move(dir)` → the free fn `step_object` (gates on `in_bounds` then `is_passable || can_push`, pushing before it relocates, and returns the index of any solid object it bumped). Resolution is two-phase — mutate the board collecting `(bumped, bumper)` pairs, then drop the board borrow and fire `run_bump` for each (a `bump` script reads `Board.*`, so no `board_mut` borrow may be held while it runs). A bumped object's own emitted actions wait for the next tick's pump (no same-tick cascade). `drain_errors()` moves the script error sink into `log`. 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()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) 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). -- 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):** 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. +- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc>)` (registers the API, compiles scripts, reports compile/unknown-script failures onto the error sink; runs nothing). +- Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), and `bump(id)` (the bumper's object index, or `-1` for the player), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(idx, id)`; runtime errors go to the error sink (drained to the log), not fatal. +- **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered 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`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). Each getter briefly borrows the shared `Board`. +- **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)` append an `Action` (`Move`/`SetTile`/`Log`; `Action::time_cost()` is `MOVE_COST = 0.25` s for `Move`, else 0) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`). +- The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()` and `Queue.clear()`. Safe to mutate from script because the host only touches output queues between calls (during `pump`). +- **Sender identity:** `source` (which object issued an action) 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. +- **TODO in-code:** `BoardAction.source` (and `ObjectRuntime.object_index`, and the `QueueMap` keys) 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 @@ -215,16 +216,25 @@ 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 +# optional, run every frame; dt = elapsed seconds. move() costs 250 ms, so the +# object steps ~4 cells/sec regardless of frame rate. blocked(dir) avoids walking +# into a solid (or another object's already-queued move); Queue.length()/Queue.clear() +# inspect/empty this object's own pending-action queue. +fn tick(dt) { + if Queue.length() == 0 && !blocked(North) { move(North); } +} +# optional, fires when something steps into this object; id = bumper's array index, +# or -1 for the player. +fn bump(id) { log(`bumped by ${id}`); } """ ``` -Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coordinates **or** a single grid char to locate (convention `"@"`; that cell loads as `Empty`, like an object placeholder). 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, as does any grid character that is neither a `[palette]` key nor an object/player `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): a placement char that appears multiple times uses the first occurrence, a missing object char drops that object, a missing player char falls back to `(0, 0)`, and one-solid-per-cell conflicts drop the later solid — each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player joins one-solid-per-cell** and *wins* its cell: it is placed first, silently clearing solid terrain under it and dropping any solid object that lands there. 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` accepts either `[x, y]` coordinates **or** a single grid char to locate (convention `"@"`; that cell loads as `Empty`, like an object placeholder). 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, as does any grid character that is neither a `[palette]` key nor an object/player `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): a placement char that appears multiple times uses the first occurrence, a missing object char drops that object, a missing player char falls back to `(0, 0)`, and one-solid-per-cell conflicts drop the later solid — each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player joins one-solid-per-cell** and *wins* its cell: it is placed first, silently clearing solid terrain under it and dropping any solid object that lands there. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, and `log(s)`. Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object (a blocked move still costs the cooldown), so objects can't act infinitely fast. When two objects move into the same cell on one tick, **array order wins** (the earlier object lands; the later is pushed or blocked) and the object that was moved into receives `bump(id)`. ### Key design decisions - **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`solid`, `opaque`, `pushable`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site. -- **One solid per cell** — at most one solid entity (a solid grid archetype *or* a solid object, and conceptually the player) may occupy a cell. The map loader enforces this: a solid object landing on an already-solid cell is dropped (recorded via `report_error`). The player is placed first and *wins* its cell (silently clearing solid terrain / dropping a conflicting object). `Board::solid_at` relies on this invariant. +- **One solid per cell** — at most one solid entity (the player, a solid grid archetype, *or* a solid object) may occupy a cell. The player is a first-class solid: `Board::solid_at` reports `Solid::Player` for the player's cell (checked first), it blocks movers, and it is **pushable in any direction** — a push chain that reaches the player slides the player along (and is rejected if the player has nowhere to go, e.g. against a wall). The map loader enforces the invariant: a solid object landing on an already-solid cell is dropped (recorded via `report_error`); the player *wins* its cell — its resolved position (including the `(0, 0)` fallback when `player_start` is unresolvable) silently clears any solid terrain under it and drops a conflicting object. `Board::solid_at` relies on this invariant. - **`cells: Vec<(Glyph, Archetype)>`** — each cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient. - **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games. Adding a variant: the `match`es in `behavior()`/`name()`/`default_glyph()` are exhaustive (the compiler flags them), but **`TryFrom<&str>` and `ALL_ARCHETYPES` are not** — forget the `TryFrom` arm and the archetype silently loads as `ErrorBlock`. Also update the map-format example. - **`Board` is the complete unit** — grid, player, objects, and portals all live on `Board`, matching how ZZT treats a "board". No separate wrapper struct. @@ -245,7 +255,7 @@ Today's baked-in assumptions that will need to change (don't make `Board.player` ### Other not-yet-implemented threads -- **Scripting growth** — more event hooks beyond `init`/`tick` (e.g. `on_touch` when the player steps onto an object), a larger command/read vocabulary, and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call). -- **Stable object ids** — `Command.source` / object identity are array indices into `Board::objects`, which break under spawn/destroy/reorder; replace with monotonic unique ids (a `// TODO` marks this in `script.rs`). +- **Scripting growth** — more event hooks beyond `init`/`tick`/`bump` (e.g. `on_touch` when the player steps onto an object), a larger action/read vocabulary (more actions could carry a `time_cost`), and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call). +- **Stable object ids** — `BoardAction.source` / `ObjectRuntime.object_index` / `QueueMap` keys / `bump` ids are array indices into `Board::objects`, which break under spawn/destroy/reorder; replace with monotonic unique ids (a `// TODO` marks this in `script.rs`). - **Portals & multi-board** — `PortalDef` is parsed but not runtime-wired; there is no multi-board world/loading yet (the engine loads a single map). - **Load-error surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup). diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 0c9b742..29c200c 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -1,5 +1,5 @@ use crate::log::LogLine; -use crate::script::{Direction, GameCommand, ScriptHost}; +use crate::script::{Action, Direction, ScriptHost}; use color::Rgba8; use serde::{Deserialize, Serialize}; use std::cell::{Ref, RefCell, RefMut}; @@ -352,10 +352,13 @@ impl ObjectDef { /// The single solid occupant of a board cell, returned by [`Board::solid_at`]. /// -/// At most one solid — a grid [`Archetype`] *or* an [`ObjectDef`] — may occupy a -/// cell (the invariant enforced at load time), so this represents the one thing a -/// mover would collide with there. +/// At most one solid — the player, a grid [`Archetype`], *or* an [`ObjectDef`] — may +/// occupy a cell (the invariant enforced at load time), so this represents the one +/// thing a mover would collide with there. pub enum Solid<'a> { + /// The player occupies the cell. The player is solid (it blocks movers) and + /// pushable in any direction (see [`Board::is_pushable`]). + Player, /// The cell's grid archetype is itself solid (e.g. [`Archetype::Wall`]). Cell(Archetype), /// A solid [`ObjectDef`] occupies the cell. @@ -510,6 +513,10 @@ impl Board { /// [`crate::map_file`]), this returns that one occupant or `None`. /// Panics if `x` or `y` are out of bounds. pub fn solid_at(&self, x: usize, y: usize) -> Option> { + // The player wins its cell (load-time invariant), so it is the solid there. + if self.player.x == x as i32 && self.player.y == y as i32 { + return Some(Solid::Player); + } // A solid object shadows the grid cell it sits on. if let Some(obj) = self.object_at(x, y) && obj.solid @@ -539,6 +546,7 @@ impl Board { /// can be shoved in any direction. fn is_pushable(&self, x: usize, y: usize, dir: Direction) -> bool { match self.solid_at(x, y) { + Some(Solid::Player) => true, // the player is pushable in any direction Some(Solid::Cell(a)) => a.behavior().pushable.allows(dir), Some(Solid::Object(o)) => o.pushable, None => false, @@ -606,7 +614,11 @@ impl Board { /// The caller guarantees the destination is already clear. fn shift_solid(&mut self, x: usize, y: usize, dx: i32, dy: i32) { let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize); - if let Some(idx) = self.object_index_at(x, y) + // The player owns its cell, so move it before considering objects/grid. + if self.player.x == x as i32 && self.player.y == y as i32 { + self.player.x = tx as i32; + self.player.y = ty as i32; + } else if let Some(idx) = self.object_index_at(x, y) && self.objects[idx].solid { self.objects[idx].x = tx; @@ -662,7 +674,7 @@ impl GameState { scripts, }; // Surface any compile-time script errors collected during ScriptHost::new. - state.apply_commands(); + state.drain_errors(); state } @@ -681,86 +693,135 @@ impl GameState { self.log.push(line); } - /// 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. + /// Runs the `init()` hook of every scripted object, pumps their queues, and + /// resolves the resulting actions. 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.apply_commands(); + self.resolve(); } - /// 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, then applies the commands they queued. + /// 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. Counts down object + /// cooldowns, drives every scripted object's `tick(dt)` hook (pumping each queue), + /// then resolves the actions that were promoted onto the board queue. pub fn tick(&mut self, dt: Duration) { - self.scripts.run_tick(dt.as_secs_f64()); - self.apply_commands(); + let secs = dt.as_secs_f64(); + self.scripts.advance_timers(secs); + self.scripts.run_tick(secs); + self.resolve(); } - /// 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; + /// Drains errors collected by the script host into the game log. + fn drain_errors(&mut self) { + // TODO: errors are only logged for now. This is the place to halt execution / + // set an error state when a script faults. + let errors = self.scripts.take_errors(); + self.log.extend(errors); + } + + /// Resolves the board queue: applies each promoted action to the board, then fires + /// the `bump` hooks the moves triggered. + /// + /// Done in two phases so no `board_mut` borrow is held while `bump` scripts run + /// (they read the board through its getters): phase A mutates the board and records + /// `(bumped_object, bumper)` pairs; phase B fires the bumps after the borrow drops. + fn resolve(&mut self) { + let actions = self.scripts.take_board_queue(); + // Logs are collected here rather than pushed inline, since the board borrow + // below also borrows `self`. + let mut logs: Vec = Vec::new(); + let mut bumps: Vec<(usize, i64)> = Vec::new(); + { + let mut board = self.board_mut(); + for ba in actions { + match ba.action { + Action::Move(dir) => { + if let Some(bumped) = step_object(&mut board, ba.source, dir) { + bumps.push((bumped, ba.source as i64)); + } } + Action::SetTile(tile) => { + if let Some(obj) = board.objects.get_mut(ba.source) { + obj.glyph.tile = tile; + } + } + Action::Log(line) => logs.push(line), } - GameCommand::Move(dir) => self.move_object(cmd.source, dir), } } - } - - /// Moves object `idx` one cell in `dir`. The move proceeds if the target is in - /// bounds and either passable or a pushable solid the object can shove out of - /// the way (see [`Board::can_push`]). 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 target = (ox as i32 + dx, oy as i32 + dy); - if !board.in_bounds(target) { - return; - } - let (nx, ny) = (target.0 as usize, target.1 as usize); - if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) { - board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise - let obj = &mut board.objects[idx]; - obj.x = nx; - obj.y = ny; + self.log.extend(logs); + for (bumped, bumper) in bumps { + self.scripts.run_bump(bumped, bumper); } + self.drain_errors(); } /// Attempts to move the player one cell in `dir`. /// /// The move is ignored if the target cell is out of bounds, or it is neither - /// passable nor a pushable solid that can be shoved aside. No-ops silently - /// (the caller does not need to check). + /// passable nor a pushable solid that can be shoved aside. No-ops silently (the + /// caller does not need to check). If the target cell holds a solid object, that + /// object's `bump(-1)` hook fires (whether or not the player ends up moving). pub fn try_move(&mut self, dir: Direction) { - let (dx, dy): (i32, i32) = dir.into(); - let mut board = self.board_mut(); - let target = (board.player.x + dx, board.player.y + dy); - if !board.in_bounds(target) { - return; + let bumped; + { + let (dx, dy): (i32, i32) = dir.into(); + let mut board = self.board_mut(); + let target = (board.player.x + dx, board.player.y + dy); + if !board.in_bounds(target) { + return; + } + let (nx, ny) = (target.0 as usize, target.1 as usize); + // A solid object in the way is bumped by the player (id -1). + bumped = match board.solid_at(nx, ny) { + Some(Solid::Object(_)) => board.object_index_at(nx, ny), + _ => None, + }; + if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) { + board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise + board.player.x = nx as i32; + board.player.y = ny as i32; + } } - let (nx, ny) = (target.0 as usize, target.1 as usize); - if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) { - board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise - board.player.x = nx as i32; - board.player.y = ny as i32; + if let Some(idx) = bumped { + self.scripts.run_bump(idx, -1); + self.drain_errors(); } } } +/// Moves object `idx` one cell in `dir` on `board`, returning the index of a solid +/// object it bumped (its target cell was occupied by that object), if any. +/// +/// The move itself proceeds only if the target is in bounds and either passable or a +/// pushable solid the object can shove out of the way (see [`Board::can_push`]). The +/// bump is recorded whenever a solid object occupies the target — whether it gets +/// pushed aside or blocks the move — since something tried to move into it. Walls and +/// crates carry no script, so only solid objects yield a bump. +fn step_object(board: &mut Board, idx: usize, dir: Direction) -> Option { + let (dx, dy): (i32, i32) = dir.into(); + let (ox, oy) = board.objects.get(idx).map(|o| (o.x, o.y))?; + let target = (ox as i32 + dx, oy as i32 + dy); + if !board.in_bounds(target) { + return None; + } + let (nx, ny) = (target.0 as usize, target.1 as usize); + // Capture the bumped object before any push relocates it (its index is stable). + let bumped = match board.solid_at(nx, ny) { + Some(Solid::Object(_)) => board.object_index_at(nx, ny), + _ => None, + }; + if board.is_passable(nx, ny) || board.can_push(nx, ny, dir) { + board.push(nx, ny, dir); // shoves a crate/object out of the way; no-op otherwise + let obj = &mut board.objects[idx]; + obj.x = nx; + obj.y = ny; + } + bumped +} + #[cfg(test)] mod tests { use super::*; @@ -838,8 +899,9 @@ mod tests { #[test] fn solid_at_reports_wall_object_and_empty() { - // A 3×1 board: empty floor, a wall, and an empty cell holding one object. - let mut board = open_board(3, 1, (0, 0), vec![], &[]); + // A 4×1 board: empty floor, a wall, an object, and the player parked at (3,0) + // (kept off the asserted cells, since the player is itself solid). + let mut board = open_board(4, 1, (3, 0), vec![], &[]); board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall); // A solid object on the otherwise-empty cell (2, 0). board.objects.push(ObjectDef::new(2, 0)); // solid by default @@ -1249,4 +1311,269 @@ mod tests { assert_eq!(b.objects[0].x, 1); // moved east from 0 assert_eq!(b.objects[1].x, 3); // moved west from 4 } + + #[test] + fn move_cost_rate_limits_repeated_moves() { + // init queues two moves; only the first resolves immediately, the second must + // wait the full 250 ms cooldown. + let board = open_board( + 5, + 1, + (0, 0), // player to the west, out of the object's path + vec![scripted_object(1, 0, "m")], + &[("m", "fn init() { move(East); move(East); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(game.board().objects[0].x, 2); // first move applied (1 -> 2) + + // 200 ms of ticks: still inside the cooldown, no further movement. + game.tick(Duration::from_millis(100)); + game.tick(Duration::from_millis(100)); + assert_eq!(game.board().objects[0].x, 2); + + // Crossing the 250 ms mark releases the queued second move. + game.tick(Duration::from_millis(100)); + assert_eq!(game.board().objects[0].x, 3); + } + + #[test] + fn blocked_move_still_costs_cooldown() { + // A move into a wall fails but still charges the 250 ms cooldown, so a second + // queued move (into open space) is delayed just as a successful move would be. + let mut board = open_board( + 3, + 3, + (0, 0), + vec![scripted_object(1, 1, "m")], + &[("m", "fn init() { move(East); move(South); }")], + ); + wall_at(&mut board, 2, 1); // blocks the eastward move + let mut game = GameState::new(board); + game.run_init(); + // First (eastward) move is blocked by the wall: object hasn't moved. + assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 1)); + + // The blocked move charged the cooldown, so South is still pending here. + game.tick(Duration::from_millis(100)); + game.tick(Duration::from_millis(100)); + assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 1)); + + // Past 250 ms the queued South move resolves. + game.tick(Duration::from_millis(100)); + assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 2)); + } + + #[test] + fn queue_length_reports_pending_actions() { + // Two zero-cost actions are queued before the length is read, then all three + // (incl. the log) drain in one pump. + let board = open_board( + 3, + 1, + (0, 0), + vec![scripted_object(1, 0, "q")], + &[("q", "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + assert!(log_texts(&game).iter().any(|t| t == "len=2")); + assert_eq!(game.board().objects[0].glyph.tile, 6); // last set_tile won + } + + #[test] + fn queue_clear_drops_pending_actions() { + // clear() empties the output queue mid-script, so the queued moves never run. + let board = open_board( + 5, + 1, + (0, 0), + vec![scripted_object(1, 0, "c")], + &[("c", "fn init() { move(East); move(East); Queue.clear(); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + game.tick(Duration::from_millis(300)); + assert_eq!(game.board().objects[0].x, 1); // never moved + } + + #[test] + fn blocked_reports_solid_and_clear() { + // Solid ahead (a wall): blocked() is true. + let mut board = open_board( + 3, + 1, + (0, 0), + vec![scripted_object(1, 0, "b")], + &[("b", "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }")], + ); + wall_at(&mut board, 2, 0); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(game.board().objects[0].glyph.tile, 9); + + // Open ahead, nothing pending: blocked() is false. + let board = open_board( + 3, + 1, + (0, 0), + vec![scripted_object(1, 0, "b")], + &[("b", "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }")], + ); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(game.board().objects[0].glyph.tile, 7); + } + + #[test] + fn blocked_sees_earlier_objects_pending_move() { + // obj0 (earlier in the array) queues a move into (2,1); obj1 checks blocked() + // toward that same cell and must see the pending move. + let board = open_board( + 5, + 3, + (0, 0), + vec![ + scripted_object(1, 1, "mover"), + scripted_object(3, 1, "checker"), + ], + &[ + ("mover", "fn tick(dt) { move(East); }"), + ( + "checker", + "fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }", + ), + ], + ); + let mut game = GameState::new(board); + game.tick(Duration::from_millis(16)); + let b = game.board(); + assert_eq!((b.objects[0].x, b.objects[0].y), (2, 1)); // mover advanced + assert_eq!(b.objects[1].glyph.tile, 1); // checker saw the pending move + } + + #[test] + fn collision_priority_resolves_in_array_order_and_bumps() { + // Two objects move into the same empty cell (1,0). obj0 (earlier) wins it; + // obj1 is blocked and bumps obj0. obj1 itself receives no bump. + let board = open_board( + 3, + 2, + (0, 1), // player off the contested row + vec![scripted_object(0, 0, "e"), scripted_object(2, 0, "w")], + &[ + ( + "e", + "fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }", + ), + ( + "w", + "fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }", + ), + ], + ); + let mut game = GameState::new(board); + game.run_init(); + // The bump fires during resolution but its log is emitted into obj0's queue; + // a later tick (past both objects' move cooldown) flushes it to the game log. + game.tick(Duration::from_millis(300)); + { + let b = game.board(); + assert_eq!((b.objects[0].x, b.objects[0].y), (1, 0)); // obj0 won the cell + assert_eq!((b.objects[1].x, b.objects[1].y), (2, 0)); // obj1 blocked + } + let logs = log_texts(&game); + assert!(logs.iter().any(|t| t == "o0 by 1")); // obj0 bumped by obj1 + assert!(!logs.iter().any(|t| t.starts_with("o1 by"))); // obj1 not bumped + } + + #[test] + fn player_bump_fires_with_negative_one() { + // The player walks into a solid scripted object; the object is bumped with -1 + // and the player does not move onto it. + let board = open_board( + 3, + 1, + (0, 0), + vec![scripted_object(1, 0, "b")], + &[("b", "fn bump(id) { log(`bumped by ${id}`); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + game.try_move(Direction::East); + assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked + // bump's log is emitted into the object's queue; a tick flushes it. + game.tick(Duration::from_millis(16)); + assert!(log_texts(&game).iter().any(|t| t == "bumped by -1")); + } + + #[test] + fn solid_at_reports_player() { + // The player's own cell is solid (and thus impassable to movers). + let board = open_board(3, 1, (1, 0), vec![], &[]); + match board.solid_at(1, 0) { + Some(Solid::Player) => {} + _ => panic!("expected Solid::Player at the player's cell"), + } + assert!(!board.is_passable(1, 0)); + } + + #[test] + fn push_into_player_pushes_player() { + // Crate shoved east into the player slides the player along into open space. + let mut board = open_board(4, 1, (2, 0), vec![], &[]); + crate_at(&mut board, 1, 0); + assert!(board.can_push(1, 0, Direction::East)); + board.push(1, 0, Direction::East); + assert_eq!(board.get(1, 0).1, Archetype::Empty); // crate left its cell + assert_eq!(board.get(2, 0).1, Archetype::Crate); // crate took the player's old cell + assert_eq!((board.player.x, board.player.y), (3, 0)); // player shoved east + } + + #[test] + fn push_into_player_blocked_by_wall() { + // Player backed against a wall: the push has nowhere to go, so nothing moves + // and the player is never overlapped. + let mut board = open_board(4, 1, (2, 0), vec![], &[]); + crate_at(&mut board, 1, 0); + wall_at(&mut board, 3, 0); + assert!(!board.can_push(1, 0, Direction::East)); + board.push(1, 0, Direction::East); // no-op + assert_eq!(board.get(1, 0).1, Archetype::Crate); + assert_eq!((board.player.x, board.player.y), (2, 0)); + } + + #[test] + fn object_push_into_player() { + // A scripted object moving into the player pushes the player when there's room. + let board = open_board( + 5, + 1, + (2, 0), + vec![scripted_object(1, 0, "m")], + &[("m", "fn init() { move(East); }")], + ); + let mut game = GameState::new(board); + game.run_init(); + { + let b = game.board(); + assert_eq!((b.objects[0].x, b.objects[0].y), (2, 0)); // object took player's cell + assert_eq!((b.player.x, b.player.y), (3, 0)); // player shoved east + } + + // With a wall behind the player, the object is blocked and nothing moves. + let mut board = open_board( + 4, + 1, + (2, 0), + vec![scripted_object(1, 0, "m")], + &[("m", "fn init() { move(East); }")], + ); + wall_at(&mut board, 3, 0); + let mut game = GameState::new(board); + game.run_init(); + let b = game.board(); + assert_eq!((b.objects[0].x, b.objects[0].y), (1, 0)); // object blocked + assert_eq!((b.player.x, b.player.y), (2, 0)); // player not pushed + } } diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 2826911..2dd10ba 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -866,6 +866,17 @@ content = """ assert!(!b.is_valid()); } + #[test] + fn player_fallback_to_origin_clears_solid_terrain() { + // The '@' char is absent, so the player falls back to (0, 0) — which holds a + // wall. The player wins its cell: the wall is cleared so one-solid-per-cell + // holds (the player is itself solid). The fallback is still reported. + let b = load_board(&player_map(3, "\"@\"", "#..", "")); + assert_eq!((b.player.x, b.player.y), (0, 0)); + assert_eq!(b.get(0, 0).1, Archetype::Empty); // wall cleared under the player + assert!(!b.is_valid()); + } + /// Minimal valid TOML with a configurable grid, used as a base for dimension tests. fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String { let content = grid_rows.join("\n"); diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index f82ed95..e3390a7 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -1,62 +1,81 @@ //! Rhai scripting runtime for board objects. //! //! [`ScriptHost`] owns the Rhai [`Engine`], the compiled scripts referenced by a -//! board's objects, and a persistent per-object [`Scope`]. It drives two optional +//! board's objects, and a persistent per-object [`Scope`]. It drives the optional //! lifecycle hooks on each scripted object: //! //! - `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`]). +//! - `bump(id)` — run when another mover steps into this object's cell, with the +//! bumper's object index (`-1` for the player); see [`ScriptHost::run_bump`]. //! -//! ## Reads vs. writes +//! ## Actions, queues, and rate limiting //! -//! 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 don't mutate the world directly. Each write host function (`move`, +//! `set_tile`, `log`) appends an [`Action`] to the issuing object's **output +//! queue** (a per-object FIFO). Actions leave that queue one batch at a time via +//! [`ScriptHost::pump`], which promotes them onto the shared **board queue**: //! -//! 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. +//! - A [`pump`](ScriptHost::pump) pulls the leading run of zero-cost actions plus at +//! most one action with a time cost (currently only [`Action::Move`], 250 ms). +//! - Pulling a timed action arms the object's **ready timer**; while it is running no +//! further actions are pulled (this is what caps an object's speed). [`tick`] ticks +//! the timer down via [`advance_timers`](ScriptHost::advance_timers). //! +//! [`crate::game::GameState`] drains the board queue ([`take_board_queue`]) and applies +//! each action *after* the script batch, preserving the borrow discipline (scripts only +//! ever read the board during execution). Which object issued an action is read from the +//! per-call **tag** (set to the object index in [`ScriptHost::run`]). +//! +//! [`tick`]: ScriptHost::run_tick +//! [`take_board_queue`]: ScriptHost::take_board_queue //! [`GameState`]: crate::game::GameState use crate::game::Board; use crate::log::LogLine; use rhai::{AST, CallFnOptions, Engine, FuncArgs, ImmutableString, NativeCallContext, Scope}; use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; -/// Shared queue the write host functions push into; drained by [`ScriptHost::take_commands`]. -type CommandQueue = Rc>>; +/// How long a move occupies an object before it can act again, in seconds. +pub const MOVE_COST: f64 = 0.25; -/// 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 { +/// One deferred mutation emitted by a script, applied by [`crate::game::GameState`] +/// after it is promoted onto the board queue. +pub enum Action { /// Move the source object one cell in a direction (subject to passability). Move(Direction), /// Set the source object's glyph tile index. SetTile(u32), /// Append a 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), +} + +impl Action { + /// How long performing this action keeps the object busy, in seconds. + /// + /// Only [`Action::Move`] has a cost ([`MOVE_COST`]); zero-cost actions can be + /// drained several-per-pump and never arm the ready timer. + pub fn time_cost(&self) -> f64 { + match self { + Action::Move(_) => MOVE_COST, + Action::SetTile(_) | Action::Log(_) => 0.0, + } + } +} + +/// An action promoted from an object's output queue onto the board queue, tagged +/// with the object that issued it. +pub struct BoardAction { + /// The object that issued the action. + // 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, + /// The action to apply. + pub action: Action, } /// A cardinal movement direction. @@ -85,6 +104,22 @@ impl From for (i32, i32) { /// read-only by construction. type BoardRef = Rc>; +/// A single object's output queue, shared between the host (which pumps it between +/// script calls) and the script (which reads/mutates it through the `Queue` object). +type ObjQueue = Rc>>; + +/// The board queue: actions promoted from object queues, awaiting resolution. Shared +/// so the `blocked` host function can scan pending moves during script execution. +type BoardQueue = Rc>>; + +/// Maps an object index to its output queue, so the global write host functions can +/// route an emitted action to the right object via the per-call tag. +type QueueMap = Rc>>; + +/// Channel for engine/compile/runtime errors, surfaced straight to the game log +/// (errors never go through the action queues). +type ErrorSink = Rc>>; + /// A compiled script plus which lifecycle hooks it defines. struct CompiledScript { /// The compiled Rhai AST (function definitions plus any top-level code). @@ -93,20 +128,27 @@ struct CompiledScript { has_init: bool, /// Whether the script defines `fn tick(dt)` (one parameter). has_tick: bool, + /// Whether the script defines `fn bump(id)` (one parameter). + has_bump: bool, } -/// The runtime state of one scripted object: which script it runs and its own -/// persistent Rhai scope. +/// The runtime state of one scripted object: which script it runs, its persistent +/// Rhai scope, its pending actions, and its cooldown timer. struct ObjectRuntime { /// 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. + /// per-call tag so host functions know which object issued an action. + // TODO: see [`BoardAction::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 (holds the `board` view + direction constants, and can - /// hold object-local state in the future). Persists across calls. + /// Per-object scope (holds the `Board`/`Queue` views + direction constants, and + /// can hold object-local state in the future). Persists across calls. scope: Scope<'static>, + /// This object's output queue (shared with its scope's `Queue` object). + output_queue: ObjQueue, + /// Seconds remaining before this object may pull another action. While `> 0` + /// nothing is pulled from [`output_queue`](Self::output_queue). + ready_timer: f64, } /// Owns the Rhai engine and per-object script state for a board. @@ -117,22 +159,27 @@ pub struct ScriptHost { scripts: HashMap, /// One entry per object that has a successfully compiled script. objects: Vec, - /// Queue the write host functions push into; drained by [`Self::take_commands`]. - commands: CommandQueue, + /// Actions promoted from object queues, drained by [`Self::take_board_queue`]. + board_queue: BoardQueue, + /// Errors collected during compile/run, drained by [`Self::take_errors`]. + errors: ErrorSink, } impl ScriptHost { - /// 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. + /// Builds a host for the board behind `board_cell`: registers the read/write API, + /// compiles every script referenced by an object (once each), and creates a fresh + /// scope and output queue per scripted object. Compile errors and references to + /// unknown scripts are queued onto the error sink (retrievable via + /// [`Self::take_errors`]); no script is run here. pub fn new(board_cell: &BoardRef) -> Self { - let commands: CommandQueue = Rc::new(RefCell::new(Vec::new())); + let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new())); + let queues: QueueMap = Rc::new(RefCell::new(HashMap::new())); + let errors: ErrorSink = Rc::new(RefCell::new(Vec::new())); let mut engine = Engine::new(); - register_read_api(&mut engine); - register_write_api(&mut engine, &commands); + register_read_api(&mut engine, board_cell, &board_queue); + register_write_api(&mut engine, &queues); + register_queue_api(&mut engine); let board = board_cell.borrow(); @@ -140,7 +187,7 @@ impl ScriptHost { // object that references the script. let mut scripts: HashMap = HashMap::new(); let mut failed: HashSet = HashSet::new(); - for (i, obj) in board.objects.iter().enumerate() { + for obj in board.objects.iter() { let Some(name) = obj.script_name.as_ref() else { continue; }; @@ -151,57 +198,55 @@ impl ScriptHost { Some(src) => match engine.compile(src) { Ok(ast) => { // Detect the optional hooks by name and arity. - let has_init = ast - .iter_functions() - .any(|f| f.name == "init" && f.params.is_empty()); - let has_tick = ast - .iter_functions() - .any(|f| f.name == "tick" && f.params.len() == 1); - scripts.insert( - name.clone(), - CompiledScript { - ast, - has_init, - has_tick, - }, - ); + let defines = |n: &str, params: usize| { + ast.iter_functions() + .any(|f| f.name == n && f.params.len() == params) + }; + let compiled = CompiledScript { + has_init: defines("init", 0), + has_tick: defines("tick", 1), + has_bump: defines("bump", 1), + ast, + }; + scripts.insert(name.clone(), compiled); } Err(err) => { failed.insert(name.clone()); - commands.borrow_mut().push(Command { - source: i, - kind: GameCommand::Error(format!( - "script '{name}' failed to compile: {err}" - )), - }); + errors + .borrow_mut() + .push(LogLine::raw(format!("script '{name}' failed to compile: {err}"))); } }, None => { failed.insert(name.clone()); - commands.borrow_mut().push(Command { - source: i, - kind: GameCommand::Error(format!( - "object references unknown script '{name}'" - )), - }); + errors + .borrow_mut() + .push(LogLine::raw(format!("object references unknown script '{name}'"))); } } } - // One runtime (independent scope) per object whose script compiled. - let objects = board - .objects - .iter() - .enumerate() - .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: new_object_scope(board_cell), - }) - }) - .collect(); + // One runtime (independent scope + output queue) per object whose script + // compiled. The queue is registered in `queues` so host functions can find + // it by the object's index, and shared into the scope as the `Queue` object. + let mut objects = Vec::new(); + for (i, obj) in board.objects.iter().enumerate() { + let Some(name) = obj.script_name.as_ref() else { + continue; + }; + if !scripts.contains_key(name) { + continue; + } + let queue: ObjQueue = Rc::new(RefCell::new(VecDeque::new())); + queues.borrow_mut().insert(i, queue.clone()); + objects.push(ObjectRuntime { + object_index: i, + script_name: name.clone(), + scope: new_object_scope(board_cell, &queue), + output_queue: queue, + ready_timer: 0.0, + }); + } drop(board); @@ -209,112 +254,232 @@ impl ScriptHost { engine, scripts, objects, - commands, + board_queue, + errors, } } - /// Calls `init()` on every scripted object that defines it. + /// Calls `init()` on every scripted object that defines it, pumping each object's + /// queue afterward. pub fn run_init(&mut self) { self.run("init", |c| c.has_init, ()); } - /// Calls `tick(dt)` on every scripted object that defines it, passing the - /// elapsed seconds since the last tick. + /// Calls `tick(dt)` on every scripted object that defines it, passing the elapsed + /// seconds, then pumps each object's queue. pub fn run_tick(&mut self, dt: f64) { self.run("tick", |c| c.has_tick, (dt,)); } - /// 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`, 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, - defined: fn(&CompiledScript) -> bool, - args: A, - ) { - // Split the borrow so we can call the engine while mutating each scope. + /// Calls `bump(id)` on the object at `object_index`, if it defines the hook. + /// + /// `bumper` is the index of the object that moved into it, or `-1` for the player. + /// Does **not** pump: any actions the hook emits wait for the next tick's pump, so + /// a bump can't cascade within the same resolution pass. + pub fn run_bump(&mut self, object_index: usize, bumper: i64) { let Self { engine, scripts, objects, - commands, + errors, + .. } = self; - for obj in objects.iter_mut() { - let Some(compiled) = scripts.get(&obj.script_name) else { - continue; + let Some(obj) = objects.iter_mut().find(|o| o.object_index == object_index) else { + return; + }; + let Some(compiled) = scripts.get(&obj.script_name) else { + return; + }; + if !compiled.has_bump { + return; + } + let options = CallFnOptions::default().with_tag(object_index as i64); + if let Err(err) = + engine.call_fn_with_options::<()>(options, &mut obj.scope, &compiled.ast, "bump", (bumper,)) + { + errors + .borrow_mut() + .push(LogLine::raw(format!("script '{}' bump error: {err}", obj.script_name))); + } + } + + /// Counts every object's ready timer down by `dt` (floored at zero). Call once per + /// frame before [`run_tick`](Self::run_tick). + pub fn advance_timers(&mut self, dt: f64) { + for obj in &mut self.objects { + obj.ready_timer = (obj.ready_timer - dt).max(0.0); + } + } + + /// Removes and returns the actions promoted onto the board queue. + pub fn take_board_queue(&mut self) -> Vec { + std::mem::take(&mut self.board_queue.borrow_mut()) + } + + /// Removes and returns the errors collected since the last drain. + pub fn take_errors(&mut self) -> Vec { + std::mem::take(&mut self.errors.borrow_mut()) + } + + /// Promotes ready actions from object `i`'s output queue onto the board queue. + /// + /// While the object's ready timer is running, nothing is pulled. Otherwise the + /// leading run of zero-cost actions is promoted, followed by at most one timed + /// action (which arms the timer and ends the pump). The board-queue guard keeps a + /// second pump in the same tick from double-promoting. + fn pump(&mut self, i: usize) { + let oid = self.objects[i].object_index; + // Don't re-promote if this object already has actions awaiting resolution. + if self.board_queue.borrow().iter().any(|ba| ba.source == oid) { + return; + } + loop { + // A running cooldown blocks all further pulls (timed or not). + if self.objects[i].ready_timer > 0.0 { + break; + } + let queue = self.objects[i].output_queue.clone(); + let cost = match queue.borrow().front() { + Some(action) => action.time_cost(), + None => break, // queue drained }; - if !defined(compiled) { - continue; + let action = queue.borrow_mut().pop_front().expect("front checked above"); + if cost > 0.0 { + self.objects[i].ready_timer = cost; } - // 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 - )), - }); + self.board_queue.borrow_mut().push(BoardAction { source: oid, action }); + // A timed action ends the pump; zero-cost ones keep draining. + if cost > 0.0 { + break; } } } + + /// Shared driver for `init`/`tick`: for each object, call the hook if its script + /// `defined` it (tagging the call with the object index so host functions know the + /// source), then pump that object so backlogged actions drain and later objects' + /// `blocked` checks can see earlier movers. Runtime errors are captured rather than + /// aborting the batch. + fn run(&mut self, hook: &str, defined: fn(&CompiledScript) -> bool, args: A) { + for i in 0..self.objects.len() { + // Scope the split borrow so it ends before the `pump` reborrow below. + { + let Self { + engine, + scripts, + objects, + errors, + .. + } = self; + let obj = &mut objects[i]; + if let Some(compiled) = scripts.get(&obj.script_name) + && defined(compiled) + { + // 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) + { + errors.borrow_mut().push(LogLine::raw(format!( + "script '{}' {hook} error: {err}", + obj.script_name + ))); + } + } + } + self.pump(i); + } + } } -/// Registers the read-only `Board` API: a [`BoardRef`] type with getters that -/// borrow the shared state briefly. -fn register_read_api(engine: &mut Engine) { +/// Registers the read-only `Board` API (getters plus the `blocked` query). +/// +/// The getters operate on the `Board` value in scope; `blocked` instead captures a +/// clone of the world and the board queue, so it can resolve the caller's position and +/// scan pending moves. +fn register_read_api(engine: &mut Engine, board: &BoardRef, board_queue: &BoardQueue) { 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); + + let b = board.clone(); + let bq = board_queue.clone(); + engine.register_fn("blocked", move |ctx: NativeCallContext, dir: Direction| { + is_blocked(&b, &bq, source_of(&ctx), dir) + }); } -/// 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) { +/// Whether the object at `source` would bump something by moving in `dir`. +/// +/// Returns `true` if the target cell is off the board, already holds a solid, or is +/// the destination of any pending move on the board queue. The caller's own move is +/// never on the queue yet (its pump runs after its script), so it can't block itself. +/// +/// Pending moves are matched by their *immediate* target cell only; a queued move that +/// would push a chain of solids into the cell is not accounted for (an approximation). +fn is_blocked(board: &BoardRef, board_queue: &BoardQueue, source: usize, dir: Direction) -> bool { + let board = board.borrow(); + let Some(obj) = board.objects.get(source) else { + return true; + }; + let (dx, dy): (i32, i32) = dir.into(); + let target = (obj.x as i32 + dx, obj.y as i32 + dy); + if !board.in_bounds(target) { + return true; + } + let (tx, ty) = (target.0 as usize, target.1 as usize); + if board.solid_at(tx, ty).is_some() { + return true; + } + // Any pending move whose immediate target is this cell would put a solid here. + board_queue.borrow().iter().any(|ba| { + if let Action::Move(d) = &ba.action + && let Some(mover) = board.objects.get(ba.source) + { + let (mdx, mdy): (i32, i32) = (*d).into(); + return (mover.x as i32 + mdx, mover.y as i32 + mdy) == target; + } + false + }) +} + +/// Registers the write API: the `Direction` type and the `move`/`set_tile`/`log` host +/// functions, each appending an [`Action`] to the issuing object's output queue. +fn register_write_api(engine: &mut Engine, queues: &QueueMap) { engine.register_type_with_name::("Direction"); - let q = commands.clone(); + let q = queues.clone(); engine.register_fn("move", move |ctx: NativeCallContext, dir: Direction| { - q.borrow_mut().push(Command { - source: source_of(&ctx), - kind: GameCommand::Move(dir), - }); + emit(&q, source_of(&ctx), Action::Move(dir)); }); - let q = commands.clone(); + let q = queues.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), - }); + emit(&q, source_of(&ctx), Action::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())), - }); - }, - ); + let q = queues.clone(); + engine.register_fn("log", move |ctx: NativeCallContext, msg: ImmutableString| { + emit(&q, source_of(&ctx), Action::Log(LogLine::raw(msg.to_string()))); + }); +} + +/// Registers the `Queue` object: a handle to an object's output queue with `length()` +/// and `clear()`. Safe to mutate from a script because the host only touches these +/// queues between script calls (during [`ScriptHost::pump`]). +fn register_queue_api(engine: &mut Engine) { + engine.register_type_with_name::("Queue"); + engine.register_fn("length", |q: &mut ObjQueue| q.borrow().len() as i64); + engine.register_fn("clear", |q: &mut ObjQueue| q.borrow_mut().clear()); +} + +/// Appends `action` to the output queue of the object identified by `source`. +fn emit(queues: &QueueMap, source: usize, action: Action) { + if let Some(queue) = queues.borrow().get(&source) { + queue.borrow_mut().push_back(action); + } } /// Reads the issuing object's index from the call tag (set in [`ScriptHost::run`]). @@ -325,11 +490,12 @@ fn source_of(ctx: &NativeCallContext) -> usize { .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(board: &BoardRef) -> Scope<'static> { +/// Builds a fresh per-object scope, seeded with the read-only `Board` view, this +/// object's `Queue`, and the four direction constants (`North`/`South`/`East`/`West`). +fn new_object_scope(board: &BoardRef, queue: &ObjQueue) -> Scope<'static> { let mut scope = Scope::new(); scope.push_constant("Board", board.clone()); + scope.push_constant("Queue", queue.clone()); scope.push_constant("North", Direction::North); scope.push_constant("South", Direction::South); scope.push_constant("East", Direction::East); diff --git a/maps/start.toml b/maps/start.toml index 0d6452d..832e663 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -51,10 +51,37 @@ tile = "#" solid = false script_name = "greeter" +# A solid object placed by x/y that paces east on its own. Demonstrates the +# rate-limited move(), the blocked() collision check, the Queue object, and the +# bump() hook (walk the player into it to trigger a bump). +[[objects]] +x = 10 +y = 12 +fg = "#33aa33" +bg = "#000000" +tile = 1 +script_name = "mover" + [scripts] greeter = """ fn init() { log(`hello from object — player at ${Board.player_x}, ${Board.player_y}`); set_tile(2); // change my glyph to ☻ — proves the write path } +""" + +mover = """ +// move() costs 250 ms, so the object steps ~4 cells/sec no matter how often +// tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking +// into a wall (or another object's already-queued move this tick). +fn tick(dt) { + if Queue.length() == 0 { + move(East); + } +} +// Fires when something steps into this object; id is the bumper's array index, +// or -1 for the player. +fn bump(id) { + log(`mover bumped by ${id}`); +} """ \ No newline at end of file