From 325d2c27ddc6de1cb57cc084a89d15b02b9b4816 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Fri, 10 Jul 2026 01:20:26 -0500 Subject: [PATCH] drain object queues after each object --- CLAUDE.md | 2 +- docs/script-api.md | 11 +- kiln-core/CLAUDE.md | 18 +-- kiln-core/src/action.rs | 2 +- kiln-core/src/api/object_info.rs | 5 +- kiln-core/src/api/queue.rs | 13 +- kiln-core/src/game.rs | 139 +++++++++++++++---- kiln-core/src/script.rs | 81 +++++------ kiln-core/src/tests/map_file/transporters.rs | 3 +- kiln-core/src/tests/scripting.rs | 58 +++++++- kiln-tui/src/main.rs | 20 ++- kiln-tui/src/ui.rs | 11 +- maps/start.toml | 2 +- 13 files changed, 259 insertions(+), 106 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0ec56d8..c91cd7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ content = """ Palette `kind` values: the meta-kinds `empty` / `floor` / `object` / `portal` / `player`, or any **archetype name** directly (`wall`, `crate`, `hcrate`, `vcrate`, `pusher_north|south|east|west`, `transporter_north|south|east|west`, `spinner_cw|spinner_ccw`, `gem`, `heart`). For archetype/floor/object kinds, `tile`/`fg`/`bg` are optional and fall back to that kind's default glyph. An unknown `kind` becomes a visible `ErrorBlock` (logged). A floor entry uses `generator` for a procedural texture or `tile`/`fg`/`bg` for a fixed glyph. -Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`). `bump`'s `dir` is the `Direction` the bump came *from* (pointing toward the bumper), fired when *any* solid — the player, another object, or a pushed crate — presses into this object. They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object. When two objects move into the same cell, **lowest id wins** and the bumped object receives `bump` with the direction the bump came from. The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md). +Colors are `"#RRGGBB"` hex strings. The player is placed by a `kind = "player"` char, which must appear **exactly once** across all layers (missing → `(0,0)` + error; multiple → first + error); that cell is transparent. `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). Each layer's `content` multi-line string has its leading newline trimmed by TOML and must match `width × height` (the only hard error); the `fill`/`sparse` forms are always exactly sized. An unknown `kind` produces an `ErrorBlock` and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. **Loading is best-effort and nonfatal** (only a layer grid-dimension mismatch is a hard error): each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player wins its cell**: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level `[scripts]` table; objects reference a script by `script_name`. Scripts may define optional `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`). `bump`'s `dir` is the `Direction` the bump came *from* (pointing toward the bumper), fired when *any* solid — the player, another object, or a pushed crate — presses into this object. They **read** the world through `state.player.*` (e.g. `state.player.x`, `.health`, `.keys.red`), `state.board.*` (`.width`, `.height`, `.can_push(x, y, dir)`, `.passable(x, y)`, `.get(id)`, `.named(name)`, `.tagged(tag)`), and `me.*` (`me.x`, `me.y`, `me.has_tag(s)`, `me.blocked(dir)`, `me.can_push(dir)`, `me.waiting`, `me.queue`), and **write** via host functions `move(dir)`, `set_tile(n)`, `set_color(fg, bg)`, `log(s)`, `say(s)`, `scroll(lines)`, `send(target, fn [, arg])`, `set_tag(target, tag, present)`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `alter_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), and `die()` (remove the calling object). Writes don't take effect immediately within a hook: each is queued, and **at most one `move` resolves per 250 ms** per object. But each object's ready actions are **applied the moment its hook returns**, before the next object runs — objects are processed in **ascending id order** each tick, so a later object observes the board *after* every lower-id object has already moved (the one exception is actions still stuck behind a `Delay`). When two objects contend for the same cell, **lowest id wins**, and the loser — whose hook ran later — can already see the winner there. The bumped object receives `bump` with the direction the bump came from; `bump`/`send` reactions fire in a follow-up pass that settles fully within the same tick/`try_move`, bounded by a per-invocation guard that fires each `(object, hook, args)` at most once (so a bump/send cycle can't loop forever). The full scripting reference lives in [`docs/script-api.md`](docs/script-api.md). **Script state across board transitions**: Rhai `Scope` local variables reset when the `ScriptHost` is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as `Rc>` in `World::boards`. Scripts that need to persist information across transitions should encode it in board data (e.g. `set_tag(me.id, "visited", true)`) or the per-board `Registry` (which also persists across transitions). diff --git a/docs/script-api.md b/docs/script-api.md index 626174a..175839e 100644 --- a/docs/script-api.md +++ b/docs/script-api.md @@ -259,8 +259,9 @@ The Registry is shared by the whole board, so namespace per-instance keys by `me ## Write functions Scripts never mutate the world directly. Each write appends an `Action` to the calling object's -**output queue**; actions drain to a shared board queue between script calls and are resolved by the -engine after the batch. The *subject* of a write is implicit and varies by function (see the table). +**output queue**; the moment the hook returns, that object's ready actions are drained and applied +by the engine — before the next object's hook runs. The *subject* of a write is implicit and varies +by function (see the table). | Call | Subject | Cost | Description | |------|---------|------|-------------| @@ -421,10 +422,10 @@ they are shape problems. ### Unwieldy — machinery that leaks -- **The two-tier queue + `now()`/`delay()` model is subtle.** `now()` reorders by *enqueue +- **The output-queue + `now()`/`delay()` model is subtle.** `now()` reorders by *enqueue recency* ("most recently enqueued action to the front"), which is position-dependent and easy to - get wrong; the object-queue → board-queue promotion plus per-object delay draining is a lot of - implicit state to reason about for "do X, wait, do Y". + get wrong; the per-object delay draining (leading delays eaten by `dt`, then the ready run applied) + is still a fair amount of implicit state to reason about for "do X, wait, do Y". - **Two persistence mechanisms.** Tags and the `Registry` are separate stores with separate APIs; cross-board state must pick one. - **Stringly-typed dispatch.** `send` and scroll choices route by function *name*; a scroll choice diff --git a/kiln-core/CLAUDE.md b/kiln-core/CLAUDE.md index b7e578f..cc87ec9 100644 --- a/kiln-core/CLAUDE.md +++ b/kiln-core/CLAUDE.md @@ -34,7 +34,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `PortalDef { x, y, target_map, target_entry }` — parsed from map files, not yet runtime-wired. **`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`): -- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained onto the board queue between script calls — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `tags: HashSet`, `name: Option`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black. +- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `z: usize` (layer index, drives draw order), `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained into a batch of actions that `GameState` applies immediately after the object's hook — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `tags: HashSet`, `name: Option`. The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::new(x, y)` constructs with defaults (`z = 0`). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black. **`kiln-core/src/layer.rs`** — palette layers (the map-file/draw-stack unit): - `Layer { cells: Vec<(Glyph, Archetype)> }` (`pub(crate)`) — one row-major draw layer. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the layer beneath shows through; solidity comes from the archetype, independent of transparency. @@ -71,14 +71,14 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one). - `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds). - `Scroll { source: ObjectId, lines: Vec }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`. -- `GameState` — owns `world: World` (all boards as `Rc>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, `pub active_scroll: Option`, and `pub player: Player` — the game-global player state ([`player::Player`]: `health`/`max_health`/`gems`/`keys`) that persists across board transitions (it is *not* per-board; `Board::player` holds only the per-board `PlayerPos`). `player.gems` is changed at runtime by the `alter_gems(n)` script fn (e.g. grabbing a gem); `player.health` is changed by `alter_health(dh)` (clamped to `[0, max_health]`, e.g. grabbing a heart); `player.keys` is changed by `set_key(color, present)`. Each script-hook call is handed a `ScriptState` bundle — a `Copy` snapshot of `player` plus a shared handle to the active board — passed to the hook as its `state` parameter (Rhai type `State`, read via `state.player`/`state.board`; see `api::state`). Front-ends reach the active board through `board() -> Ref` / `board_mut() -> RefMut` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(dir_from)` on the solid object it presses into — directly or at the end of a chain of crates it shoves (see `Board::bump_target`), where `dir_from` is the side the bump came from; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` + an immediate `resolve()` so its `die()`/`alter_gems()`/`alter_health()` apply before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log` → `log`, `SetTile`/`SetColor` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player.gems`, `AlterHealth(dh)` → `player.health` clamped to `[0, max_health]`, `SetKey` → `player.keys`, `Shift` → `apply_shift`, `Push`/`Teleport` → board moves, `Send` → `run_send`, `Die` → `remove_object(source)`). Resolution is two-phase: mutate the board collecting `(bumped, dir_from)` pairs (the bumped object and the direction the bump came from), drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object. +- `GameState` — owns `world: World` (all boards as `Rc>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, `pub active_scroll: Option`, and `pub player: Player` — the game-global player state ([`player::Player`]: `health`/`max_health`/`gems`/`keys`) that persists across board transitions (it is *not* per-board; `Board::player` holds only the per-board `PlayerPos`). `player.gems` is changed at runtime by the `alter_gems(n)` script fn (e.g. grabbing a gem); `player.health` is changed by `alter_health(dh)` (clamped to `[0, max_health]`, e.g. grabbing a heart); `player.keys` is changed by `set_key(color, present)`. Each script-hook call is handed a `ScriptState` bundle — a `Copy` snapshot of `player` plus a shared handle to the active board — passed to the hook as its `state` parameter (Rhai type `State`, read via `state.player`/`state.board`; see `api::state`). Front-ends reach the active board through `board() -> Ref` / `board_mut() -> RefMut` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(dir_from)` on the solid object it presses into — directly or at the end of a chain of crates it shoves (see `Board::bump_target`), where `dir_from` is the side the bump came from; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` — applied immediately — so its `die()`/`alter_gems()`/`alter_health()` take effect before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` expires speech bubbles, then runs each object's `tick(dt)` hook **in ascending id order, applying that object's drained actions before the next object runs** — so a later object sees the board an earlier one just changed (the requested fix; the exception is actions still behind a `Delay`). The workhorse is `apply_actions(Vec) -> Events`: two-phase (mutate the board collecting `(bumped, dir_from)` pairs, drop the borrow, then apply the player-stat / bubble / scroll changes), applying each `Action` (`Log` → `log`, `SetTile`/`SetColor` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`, `AddGems(n)` → `player.gems`, `AlterHealth(dh)` → `player.health` clamped to `[0, max_health]`, `SetKey` → `player.keys`, `Shift` → `apply_shift`, `Push`/`Teleport` → board moves, `Die` → `remove_object(source)`). It **returns** the `bump`/`send` reactions rather than firing them; after all object hooks, `settle(events, called)` runs them (and any they cascade into) until quiescent, applying each hook's actions as it goes. `called: CalledSet` (a `HashSet<(ObjectId, fn/hook name, arg repr)>`, fresh per `tick`/`try_move`/`run_init`) records every reaction fired and skips repeats, so a bump/send cycle terminates. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object. **`kiln-core/src/action.rs`** — the `Action` enum and its supporting types: - `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s). - `ScrollLine` (`pub`) — one line of content in a `Scroll` action: `Text(String)` (plain, word-wrapped) or `Choice { choice, display }` (selectable; `choice` is sent back to the source object when the player picks it). - `SendArg` — the optional argument carried by a `Send` action / `send()` call: `None`/`Int`/`Float`/`String`, with `From`/`Into` conversions. -- `Action` (`pub(crate)`) — deferred mutation emitted by a script and applied by `GameState` after promotion onto the board queue: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never reaches the board queue — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost). -- `BoardAction { source, action }` — an `Action` promoted onto the board queue, tagged with the `ObjectId` that issued it. +- `Action` (`pub(crate)`) — deferred mutation emitted by a script, drained from the issuing object's queue and applied by `GameState::apply_actions`: `Move(Direction)`, `SetTile(u32)`, `Log(LogLine)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never drained as an action — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost). +- `BoardAction { source, action }` — an `Action` drained from an object's queue, tagged with the `ObjectId` that issued it; `GameState` applies a `Vec` per object. **`kiln-core/src/floor.rs`** — procedural floor generators: - A "floor" is just a non-solid, visible `Empty` cell on a layer (a palette entry with `kind = "floor"`). This module only owns the generators; the actual per-cell placement happens in `layer.rs` during load. @@ -89,12 +89,12 @@ The core types that were formerly monolithic in `game.rs` are now split into foc **`kiln-core/src/script.rs`** — Rhai scripting **host** (the script-facing types live in `kiln-core/src/api/`, below). The script-author's reference is [`docs/script-api.md`](../docs/script-api.md). - `Registerable` trait — `fn register(engine, error_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`. -- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap`), one persistent `Scope` per scripted object (`HashMap`), the shared board queue, and the error sink. Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the error sink; runs nothing. -- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). Driven by `run_init(state)` / `run_tick(state, dt)` / `run_bump(id, dir)` / `run_grab(state, id)`, all funneling through `run_hook_on_one`: it builds a fresh `ObjectInfo` for the object, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal. +- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap`), one persistent `Scope` per scripted object (`HashMap`), and the error sink. (There is no shared board queue: each hook call drains into a local `Vec` that it returns to `GameState`.) Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the error sink; runs nothing. +- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). Run one object at a time (the per-object loop lives in `GameState`): `run_tick_on(id, dt)` / `run_init_on(id)` / `run_bump(id, dir)` / `run_grab(id)` / `run_send(id, fn, arg)`, all funneling through `run_hook_on_one` and **returning the `Vec` they drained** for `GameState` to apply. `run_hook_on_one` builds a fresh `ObjectInfo`, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the error sink (drained to the log), not fatal. - `run_send(state, id, fn_name, arg)` — calls an arbitrary named function on an object (backs the `send()` action and scroll-choice dispatch). Picks the param list by the function's arity: 3 → `(me, state, arg)`, 2 → `(me, state)`, 1 → `(arg)`, 0 → `()`; a missing arg is `Dynamic::UNIT`. Errors if no function of that name exists. - **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore. - **Write API** (`register_write_api`) — free functions that enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `log(s)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error) and rotates that ring of cells via `Board::apply_shift`. -- **Queue draining (two-tier):** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(board_queue, dt)` promotes them onto the shared **board queue** (`Vec`): it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. `GameState` takes the board queue with `take_board_queue()` and applies it after the batch, so scripts mutate without a `&mut GameState` borrow. Errors bypass the queues via the error sink (`take_errors()`). +- **Queue draining:** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(&mut Vec, dt)` moves its ready run into a caller-supplied `Vec`: it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. The hook runner returns that `Vec` and `GameState` applies it immediately (per object), so scripts mutate without a `&mut GameState` borrow and each object sees the prior object's effects. Errors bypass the queue via the error sink (`take_errors()`). - **Constants in scope:** only `Registry` is pushed into each object's scope (the `me`/`state` views are hook *parameters* now). `register_global_constants` registers the `North`/`South`/`East`/`West` `Direction` values and the 16 named colors as a **global module**, visible at any call depth (including Rhai→Rhai calls and helper functions) — unlike scope constants such as `Registry`. - `Registry` (a `Board`-handle wrapper) — `Registry.get(key)` / `set(key, value)` / `get_or(key, default)` read and write the board's `registry: HashMap`, which **persists across board transitions**. `set(key, ())` removes a key; unsupported value types are ignored; `get_or` only returns the stored value when it matches the default's Rhai type. - **Sender identity uses stable ids:** the issuing `ObjectId` rides the per-call **tag** — `run_*` calls `call_fn_with_options(...with_tag(id)...)` and the write fns read it via `NativeCallContext::tag()` (decoded by `source_of`; id 0, never valid, is the no-source fallback). It matches `Board::objects`' `BTreeMap` keys, so it survives object spawn/destroy/reorder. @@ -104,8 +104,8 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `api::state` — `ScriptState { player: PlayerWithPos, board: BoardRef }` (Rhai type **`State`**), the `state` parameter of every hook. `from_game_state` snapshots the player and clones the board handle; getters `state.player` / `state.board`. - `api::player` — `PlayerWithPos(Player, PlayerPos)` (Rhai type **`Player`**), a `Copy` bundle of the game-global `Player` stats and the per-board `PlayerPos` so they register as one Rhai object. Getters `gems`, `health`, `max_health`, `keys` (a `Keyring`, exposing one `bool` getter per color), `x`, `y`. - `api::board` — `BoardRef = Rc>` (Rhai type **`Board`**), a read-only handle. Getters `width`/`height`; methods `can_push(x, y, dir)`, `passable(x, y)`, `get(id)` (→ `ObjectInfo` or `()`; `id <= 0` logs an error), `named(name)` (→ `ObjectInfo` or `()`), `tagged(tag)` (→ array of `ObjectInfo`). -- `api::object_info` — `ObjectInfo` (a snapshot of one object: `id`, `x`, `y`, a board handle, `script_name`, and a clone of the object's `ObjQueue`), used as both the `me` parameter and the return type of the `Board` lookups (so self and other objects are the **same** type). Getters `x`/`y`/`id`/`name`/`tags`/`glyph`/`waiting`/`queue`; methods `has_tag(tag)`, `delay(secs)`, `can_push(dir)`, `blocked(dir)`. `from_id`/`from_def` build it; `drain(target, dt)` promotes the underlying object's queued actions onto the board queue. -- `api::queue` — `ObjQueue(Rc>>)` (Rhai type **`Queue`**), one object's output queue, owned by its `ObjectDef` and reachable as `me.queue`. Rhai surface: getter `length`, methods `clear()`, `delay(secs)`. Rust surface used by the host: `act`, `delay` (merges a trailing delay), `now` (promote the last-enqueued action to the front), `drain` (eat leading delays by `dt`, then promote ready actions), `waiting` (front is a `Delay`). +- `api::object_info` — `ObjectInfo` (a snapshot of one object: `id`, `x`, `y`, a board handle, `script_name`, and a clone of the object's `ObjQueue`), used as both the `me` parameter and the return type of the `Board` lookups (so self and other objects are the **same** type). Getters `x`/`y`/`id`/`name`/`tags`/`glyph`/`waiting`/`queue`; methods `has_tag(tag)`, `delay(secs)`, `can_push(dir)`, `blocked(dir)`. `from_id`/`from_def` build it; `drain(&mut Vec, dt)` moves the underlying object's ready queued actions into the target `Vec`. +- `api::queue` — `ObjQueue(Rc>>)` (Rhai type **`Queue`**), one object's output queue, owned by its `ObjectDef` and reachable as `me.queue`. Rhai surface: getter `length`, methods `clear()`, `delay(secs)`. Rust surface used by the host: `act`, `delay` (merges a trailing delay), `now` (promote the last-enqueued action to the front), `drain` (eat leading delays by `dt`, then move the ready actions into a `&mut Vec`), `waiting` (front is a `Delay`). **`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the bulk of the per-layer work lives in `layer.rs`): - `MapFile { map: MapHeader, layers: Vec }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `board_script_name`; **no** `player_start` — the player is a `kind = "player"` palette char) plus the `[[layers]]` stack. Does **not** include scripts (those live in `World::scripts`). diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index b619412..cbd9623 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -28,7 +28,7 @@ pub enum ScrollLine { Choice { choice: String, display: String }, } -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, Debug)] pub enum SendArg { None, Int(i64), diff --git a/kiln-core/src/api/object_info.rs b/kiln-core/src/api/object_info.rs index cfafaa6..658f198 100644 --- a/kiln-core/src/api/object_info.rs +++ b/kiln-core/src/api/object_info.rs @@ -25,8 +25,9 @@ use rhai::{Dynamic, Engine}; use crate::api::board::BoardRef; use crate::api::queue::ObjQueue; use crate::Direction; +use crate::action::BoardAction; use crate::object_def::ObjectDef; -use crate::script::{BoardQueue, Registerable}; +use crate::script::Registerable; use crate::utils::{ErrorSink, ObjectId}; /// A snapshot of one board object, returned by `Board.tagged`, `Board.named`, @@ -69,7 +70,7 @@ impl ObjectInfo { } } - pub fn drain(&mut self, target: BoardQueue, dt: f64) { + pub fn drain(&mut self, target: &mut Vec, dt: f64) { let mut b = self.board.borrow_mut(); if let Some(def) = b.objects.get_mut(&self.id) { def.queue.drain(self.id, target, dt) diff --git a/kiln-core/src/api/queue.rs b/kiln-core/src/api/queue.rs index bb1e03c..c09613b 100644 --- a/kiln-core/src/api/queue.rs +++ b/kiln-core/src/api/queue.rs @@ -7,7 +7,7 @@ use std::collections::VecDeque; use std::rc::Rc; use rhai::{Dynamic, Engine}; use crate::action::{Action, BoardAction}; -use crate::script::{BoardQueue, Registerable}; +use crate::script::Registerable; use crate::utils::{ErrorSink, ObjectId}; /// A single object's output queue. @@ -44,10 +44,11 @@ impl ObjQueue { } } - /// Drains object `i`'s output queue onto a target queue: first advances - /// leading `Delay` actions by dt, then drains actions into the target - /// queue until we run out (or hit another delay) - pub fn drain(&mut self, source: ObjectId, target: BoardQueue, mut dt: f64) { + /// Drains object `i`'s output queue into `target`: first advances leading + /// `Delay` actions by dt, then moves the run of ready actions into `target` + /// until we run out (or hit another delay). Each ready action is tagged with + /// `source` so [`GameState`](crate::game::GameState) knows who issued it. + pub fn drain(&mut self, source: ObjectId, target: &mut Vec, mut dt: f64) { let mut queue = self.0.borrow_mut(); loop { match queue.front_mut() { @@ -63,7 +64,7 @@ impl ObjQueue { } Some(_) => { let action = queue.pop_front().unwrap(); - target.borrow_mut().push(BoardAction { source, action }); + target.push(BoardAction { source, action }); } } } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index b98ad0f..b25fe7a 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -1,12 +1,45 @@ -use crate::action::{Action, SendArg}; +use crate::action::{Action, BoardAction, SendArg}; use crate::board::Board; use crate::log::LogLine; use crate::script::ScriptHost; use crate::utils::{Direction, ObjectId, PlayerPos}; use crate::world::World; use std::cell::{Ref, RefMut}; +use std::collections::HashSet; use std::time::Duration; +/// The bump and send reactions produced while applying a batch of actions. +/// +/// [`GameState::apply_actions`] collects these but does not fire them; the +/// follow-up [`GameState::settle`] pass runs them after all object hooks, so a +/// bumped object reacts to the fully-updated board. +#[derive(Default)] +struct Events { + /// `(bumped object, direction the bump came from)` for each triggered `bump`. + bumps: Vec<(ObjectId, Direction)>, + /// `(target object, function name, argument)` for each `send`. + sends: Vec<(ObjectId, String, SendArg)>, +} + +impl Events { + /// Appends `other`'s reactions onto `self`. + fn merge(&mut self, other: Events) { + self.bumps.extend(other.bumps); + self.sends.extend(other.sends); + } + + /// Whether there is anything left to fire. + fn is_empty(&self) -> bool { + self.bumps.is_empty() && self.sends.is_empty() + } +} + +/// Records which `(object, hook/fn, args)` reactions have already fired during a +/// single `tick` / `try_move` / `run_init`. [`GameState::settle`] refuses to fire +/// a key twice, so a bump/send cascade always terminates — even if two objects +/// bump each other in a cycle, each side fires at most once. Reset per invocation. +type CalledSet = HashSet<(ObjectId, String, String)>; + /// How long a `say()` speech bubble stays on screen, in seconds. pub const SAY_DURATION: f64 = 3.0; @@ -164,8 +197,18 @@ impl GameState { /// 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.resolve(); + // Run each object's init hook in ascending id order, applying its actions + // immediately so a later object's init sees what an earlier one did. + let mut ev = Events::default(); + let ids = self.board().all_ids(); + for id in ids { + let actions = self.scripts.run_init_on(id); + ev.merge(self.apply_actions(actions)); + } + // Fire any bump/send reactions, then flush errors. + let mut called = CalledSet::new(); + self.settle(ev, &mut called); + self.drain_errors(); } /// Advances real-time game state by `dt` (the elapsed time since the last tick). @@ -178,14 +221,24 @@ impl GameState { // this runs exactly once per player interaction with a scroll. self.handle_scroll(); let secs = dt.as_secs_f64(); - self.scripts.run_tick(secs); - // Expire speech bubbles before resolving new actions so a fresh say() - // this frame isn't immediately culled. + // Expire speech bubbles once per frame, before applying new actions so a + // fresh say() this frame isn't immediately culled. self.speech_bubbles.retain_mut(|b| { b.remaining -= secs; b.remaining > 0.0 }); - self.resolve(); + // Run each object's tick in ascending id order, applying its drained + // actions immediately so the next object sees the updated board. + let mut ev = Events::default(); + let ids = self.board().all_ids(); + for id in ids { + let actions = self.scripts.run_tick_on(id, secs); + ev.merge(self.apply_actions(actions)); + } + // Fire the bump/send reactions those ticks triggered, then flush errors. + let mut called = CalledSet::new(); + self.settle(ev, &mut called); + self.drain_errors(); } /// Drains errors collected by the script host into the game log. @@ -196,15 +249,16 @@ impl GameState { self.log.extend(errors); } - /// Resolves the board queue: applies each promoted action to the board, then fires - /// the `bump` and `send` hooks the moves triggered. + /// Applies one object's drained `actions` to the board and returns the `bump` + /// and `send` reactions they triggered (fired later by [`settle`](GameState::settle)). /// /// Done in two phases so no `board_mut` borrow is held while scripts run /// (they read the board through its getters): phase A mutates the board and records - /// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B fires the - /// hooks after the borrow drops. - fn resolve(&mut self) { - let actions = self.scripts.take_board_queue(); + /// `(bumped, bumper)` and `(send_target, fn_name, arg)` tuples; phase B applies the + /// player-stat / bubble / scroll changes after the borrow drops. The collected + /// reactions are returned rather than fired here, so the caller can run them once + /// all object hooks in this pass have applied. + fn apply_actions(&mut self, actions: Vec) -> Events { // Logs are collected here rather than pushed inline, since the board borrow // below also borrows `self`. let mut logs: Vec = Vec::new(); @@ -384,13 +438,39 @@ impl GameState { self.log.push(LogLine::error(format!("set_key: unknown color {color:?}"))); } } - for (bumped, dir) in bumps { - self.scripts.run_bump(bumped, dir); + // Return the reactions for the caller's settle pass rather than firing them here. + Events { bumps, sends } + } + + /// Fires the `bump` / `send` reactions in `events` (and any they cascade into) + /// until the board is quiescent, applying each hook's actions as it runs. + /// + /// `called` records every `(object, hook/fn, args)` already fired this + /// invocation; a reaction whose key is already present is skipped. Since each + /// key fires at most once, the loop is finite even when objects bump each + /// other in a cycle — the guard is what makes bump-loops impossible. + fn settle(&mut self, initial: Events, called: &mut CalledSet) { + let mut pending = initial; + while !pending.is_empty() { + let mut next = Events::default(); + for (id, dir) in std::mem::take(&mut pending.bumps) { + // Skip a bump already fired this pass (dedup key includes the direction). + if !called.insert((id, "bump".to_string(), format!("{dir:?}"))) { + continue; + } + let actions = self.scripts.run_bump(id, dir); + next.merge(self.apply_actions(actions)); + } + for (id, fn_name, arg) in std::mem::take(&mut pending.sends) { + // Dedup key: target + function name + argument. + if !called.insert((id, fn_name.clone(), format!("{arg:?}"))) { + continue; + } + let actions = self.scripts.run_send(id, &fn_name, arg); + next.merge(self.apply_actions(actions)); + } + pending = next; } - for (target, fn_name, arg) in sends { - self.scripts.run_send(target, &fn_name, arg); - } - self.drain_errors(); } /// Consumes the active scroll, dispatching the player's choice (if any) back @@ -404,7 +484,12 @@ impl GameState { if let Some(scroll) = self.active_scroll.take() && let Some(choice) = scroll.choice { - self.scripts.run_send(scroll.source, &choice, SendArg::None); + // Dispatch the choice back to the source object and apply whatever it + // queues (plus any bump/send cascade), the same as a tick. + let actions = self.scripts.run_send(scroll.source, &choice, SendArg::None); + let ev = self.apply_actions(actions); + let mut called = CalledSet::new(); + self.settle(ev, &mut called); self.drain_errors(); } } @@ -513,17 +598,21 @@ impl GameState { self.enter_board(&target_map, &target_entry); return; } - // Fire the grab hook and resolve it immediately so the grabbed thing's + let mut ev = Events::default(); + // Fire the grab hook and apply it immediately so the grabbed thing's // die()/alter_gems() apply now — no player+object overlap survives this call. if let Some(id) = grabbed { - self.scripts.run_grab(id); - self.resolve(); + let actions = self.scripts.run_grab(id); + ev.merge(self.apply_actions(actions)); } if let Some(idx) = bumped { // The player advanced in `dir`, so the bump arrives from the opposite side. - self.scripts.run_bump(idx, dir.opposite()); - self.drain_errors(); + ev.bumps.push((idx, dir.opposite())); } + // Settle the grab/bump reactions (and any they cascade into) before returning. + let mut called = CalledSet::new(); + self.settle(ev, &mut called); + self.drain_errors(); } } diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 12d01d5..b14f023 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -40,9 +40,7 @@ use rhai::{ Array, CallFnOptions, Dynamic, Engine, ImmutableString, Module, NativeCallContext, Scope, AST, }; -use std::cell::RefCell; use std::collections::{HashMap, HashSet}; -use std::rc::Rc; use crate::api::board::BoardRef; use crate::api::object_info::ObjectInfo; use crate::api::player::PlayerWithPos; @@ -58,9 +56,6 @@ pub trait Registerable { fn register(engine: &mut Engine, error_sink: ErrorSink); } -/// The board queue: actions promoted from object queues, awaiting resolution. -pub type BoardQueue = Rc>>; - /// A compiled script plus which lifecycle hooks it defines. struct CompiledScript { ast: AST, @@ -98,7 +93,6 @@ pub struct ScriptHost { engine: Engine, scripts: HashMap, scopes: HashMap>, - board_queue: BoardQueue, errors: ErrorSink, board: BoardRef } @@ -112,7 +106,6 @@ impl ScriptHost { /// `scripts` is the world-level script pool (script name → Rhai source); it is /// read only during construction and not retained afterward. pub fn new(board_ref: BoardRef, player: PlayerRef, script_sources: &HashMap) -> Self { - let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new())); let errors = ErrorSink::new(); let mut scopes = HashMap::new(); @@ -196,37 +189,41 @@ impl ScriptHost { Self { engine, scripts, - board_queue, errors, scopes, board: board_ref } } - /// Calls `tick(dt)` on every scripted object that defines it, then drains queues. - pub fn run_tick(&mut self, dt: f64) { - self.run_hook_on_all(Hook::Tick, dt); + /// Runs the `tick(me, dt)` hook on one object and returns the actions it + /// drained (see [`run_hook_on_one`](ScriptHost::run_hook_on_one)). + pub(crate) fn run_tick_on(&mut self, id: ObjectId, dt: f64) -> Vec { + self.run_hook_on(Hook::Tick, id, dt) } - pub fn run_init(&mut self) { - self.run_hook_on_all(Hook::Init, 0.0) + /// Runs the `init(me)` hook on one object and returns the actions it drained. + pub(crate) fn run_init_on(&mut self, id: ObjectId) -> Vec { + self.run_hook_on(Hook::Init, id, 0.0) } - /// Run the given hook on every object that defines it. For hooks other than - /// `Tick`, dt should just be 0.0 - fn run_hook_on_all(&mut self, hook: Hook, dt: f64) { - let all_ids = self.board.borrow().all_ids(); + /// Runs `hook` on the single object `id` and returns the actions it drained. + /// + /// The `dt` arg is only meaningful for `Tick` (it becomes the hook's `dt` + /// parameter and paces the object's delay draining); pass `0.0` otherwise. + pub(crate) fn run_hook_on(&mut self, hook: Hook, id: ObjectId, dt: f64) -> Vec { let arg = match hook { Hook::Tick => Some(Dynamic::from(dt)), _ => None, }; - - for id in all_ids { - self.run_hook_on_one(hook, id, arg.clone(), dt) - } + self.run_hook_on_one(hook, id, arg, dt) } - fn run_hook_on_one(&mut self, hook: Hook, id: ObjectId, arg: Option, dt: f64) { + /// Calls one lifecycle `hook` on object `id` (if the script defines it), then + /// drains that object's ready actions into a fresh `Vec` and returns them — + /// [`GameState`](crate::game::GameState) applies them immediately, before the + /// next object runs, so each object sees the board state its actions run against. + fn run_hook_on_one(&mut self, hook: Hook, id: ObjectId, arg: Option, dt: f64) -> Vec { + let mut actions = Vec::new(); if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) { if let Some(script_key) = info.script_name.as_ref() && let Some(script) = self.scripts.get(script_key) @@ -250,29 +247,29 @@ impl ScriptHost { } // Run the drain regardless of if we have the hook, otherwise // things with no tick will never advance past a delay - info.drain(self.board_queue.clone(), dt) + info.drain(&mut actions, dt) } } else { unreachable!("Object not found"); } + actions } /// Calls `bump(dir)` on the object with [`ObjectId`] `id`, if it defines the - /// hook. `dir` is the [`Direction`] the bump came *from* (it points from the - /// bumped object toward the bumper). After the hook, drains the object's queue - /// with `dt = 0`. - pub fn run_bump(&mut self, id: ObjectId, dir: Direction) { - self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0); + /// hook, and returns the actions it drained. `dir` is the [`Direction`] the + /// bump came *from* (it points from the bumped object toward the bumper). + pub(crate) fn run_bump(&mut self, id: ObjectId, dir: Direction) -> Vec { + self.run_hook_on_one(Hook::Bump, id, Some(Dynamic::from(dir)), 0.0) } /// Calls `grab()` on the object with [`ObjectId`] `object_id`, if it defines the - /// hook, then drains its queue with `dt = 0`. + /// hook, and returns the actions it drained. /// - /// Fired when the player walks onto a grab object or a grab object is pushed - /// into the player (see [`GameState`](crate::game::GameState)). The hook - /// typically increments a player stat and removes the object via `die()`. - pub fn run_grab(&mut self, object_id: ObjectId) { - self.run_hook_on_one(Hook::Grab, object_id, None, 0.0); + /// Fired when the player walks onto a grab object (see + /// [`GameState`](crate::game::GameState)). The hook typically increments a + /// player stat and removes the object via `die()`. + pub(crate) fn run_grab(&mut self, object_id: ObjectId) -> Vec { + self.run_hook_on_one(Hook::Grab, object_id, None, 0.0) } /// Calls the named function on the object with [`ObjectId`] `target_id`. @@ -282,8 +279,10 @@ impl ScriptHost { /// - If it has arity 1, we pass the ObjectInfo alone /// - If it has arity 0, we pass nothing /// - /// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT` - pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) { + /// In cases where the arg isn't provided but we have the arity, we pass `Dynamic::UNIT`. + /// Returns the actions the call drained. + pub(crate) fn run_send(&mut self, id: ObjectId, fn_name: &str, arg: SendArg) -> Vec { + let mut actions = Vec::new(); if let Some(mut info) = ObjectInfo::from_id(id, self.board.clone()) { if let Some(script_key) = info.script_name.as_ref() && let Some(script) = self.scripts.get(script_key) @@ -301,7 +300,7 @@ impl ScriptHost { // If it's not there at all, just bail: if arities.is_empty() { self.errors.error(format!("script '{}' send({}) error: function not found", script_key, fn_name)); - return + return actions; } // Assemble the args @@ -324,16 +323,12 @@ impl ScriptHost { ) { self.errors.error(format!("script '{}' send({}) error: {err}", script_key, fn_name)); } - info.drain(self.board_queue.clone(), 0.0) + info.drain(&mut actions, 0.0) } } else { unreachable!("Object id not found, tried to send"); } - } - - /// Removes and returns the actions promoted onto the board queue. - pub(crate) fn take_board_queue(&mut self) -> Vec { - std::mem::take(&mut self.board_queue.borrow_mut()) + actions } /// Removes and returns the errors collected since the last drain. diff --git a/kiln-core/src/tests/map_file/transporters.rs b/kiln-core/src/tests/map_file/transporters.rs index 3621b54..46c664f 100644 --- a/kiln-core/src/tests/map_file/transporters.rs +++ b/kiln-core/src/tests/map_file/transporters.rs @@ -46,7 +46,8 @@ fn bumping_a_transporter_drops_you_on_its_far_side() { game.run_init(); // Walk east into the transporter: it's solid so the player doesn't step onto - // it, but the bump queues a teleport that the next tick applies. + // it, but the bump fires a teleport that resolves within this try_move. The + // trailing tick just advances the transporter's idle animation. game.try_move(Direction::East); game.tick(Duration::from_secs_f64(0.1)); diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 055a8fe..2bc443a 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -295,8 +295,8 @@ fn scroll_opens_on_player_bump() { )]), ); game.run_init(); + // The bump resolves within try_move now, so the scroll is open immediately. game.try_move(Direction::East); - game.tick(Duration::from_millis(16)); let scroll = game .active_scroll @@ -318,11 +318,11 @@ fn handle_scroll_without_choice_clears_it() { scripts_from(&[("s", r#"fn bump(m,dir) { scroll(["Hello"]); }"#)]), ); game.run_init(); + // The bump resolves within try_move, so the scroll is open right away. game.try_move(Direction::East); - game.tick(Duration::from_millis(16)); assert!(game.active_scroll.is_some()); - // No choice set — next tick clears the scroll without dispatching. + // No choice set — a tick clears the scroll without dispatching. game.tick(Duration::from_millis(16)); assert!(game.active_scroll.is_none()); } @@ -343,8 +343,8 @@ fn handle_scroll_with_choice_dispatches_send_to_source() { )]), ); game.run_init(); + // The bump resolves within try_move, so the scroll is open right away. game.try_move(Direction::East); - game.tick(Duration::from_millis(16)); assert!(game.active_scroll.is_some()); // Set the choice, then tick — handle_scroll dispatches "eat" and resolve picks up the log. @@ -356,3 +356,53 @@ fn handle_scroll_with_choice_dispatches_send_to_source() { "eat() should have logged" ); } + +#[test] +fn a_later_object_sees_an_earlier_objects_move_this_tick() { + // The core of the epic: each object's queued actions apply immediately, before + // the next (higher-id) object runs its hook. Object A (id 1) at (0,0) moves East + // onto (1,0); object B (id 2) at (1,1) then checks the cell to its North (1,0). + // Because A already moved there this tick, B observes it as blocked. Under the + // old collect-all-then-apply model B would have seen (1,0) still empty. + let a = scripted_object(0, 0, "a"); + let b = scripted_object(1, 1, "b"); + let board = open_board(3, 2, (2, 1), vec![a, b]); + let mut game = GameState::with_scripts( + board, + scripts_from(&[ + ("a", "fn tick(m,dt) { if m.queue.length == 0 { move(East); } }"), + ("b", r#"fn tick(m,dt) { log(if m.blocked(North) { "blocked" } else { "clear" }); }"#), + ]), + ); + game.run_init(); + + game.tick(Duration::from_millis(16)); + + // A moved onto (1,0), and B saw it there the same tick. + assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 0)); + assert_eq!(log_texts(&game), vec!["blocked"]); +} + +#[test] +fn a_send_cycle_terminates_via_the_called_guard() { + // Two objects send "go" to each other in a cycle. Without the per-invocation + // "already-called" guard this would recurse forever; with it, each (object, fn, + // args) fires at most once, so the cascade settles after one round-trip. That the + // call returns at all — and logs exactly one "B" then one "A" — proves it. + let a = scripted_object(0, 0, "a"); + let b = scripted_object(1, 0, "b"); + let board = open_board(3, 1, (2, 0), vec![a, b]); + let mut game = GameState::with_scripts( + board, + scripts_from(&[ + ("a", r#"fn init(m) { send(2, "poke"); } fn poke(m) { log("A"); send(2, "poke"); }"#), + ("b", r#"fn poke(m) { log("B"); send(1, "poke"); }"#), + ]), + ); + + game.run_init(); + + // B.poke fires once (from A.init's send), then A.poke once (from B.poke's send); + // A.poke's re-send to B.poke is a repeat key and is skipped, so the cascade stops. + assert_eq!(log_texts(&game), vec!["B", "A"]); +} diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 470ca20..958be52 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -36,7 +36,7 @@ use crate::input::{ use crate::log::{LogWidget, log_preview_line}; use crate::menu::MenuWidget; use crate::mode::{Mode, PendingMode}; -use crate::scroll_overlay::ScrollOverlayWidget; +use crate::scroll_overlay::{ScrollOpenAnimation, ScrollOverlayWidget}; use crate::speech::SpeechBubblesWidget; use crate::status::StatusSidebarWidget; use crate::ui::Ui; @@ -142,6 +142,11 @@ fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) -> // only changed cells are actually written to the terminal. terminal.draw(|frame| draw(frame, mode, ui))?; + // Remember whether a scroll overlay was already open, so we can detect one + // that opens this frame — from a player bump during input *or* from a tick — + // and play its open animation exactly once (see after the tick below). + let scroll_active_before = matches!(mode, Mode::Play(g) if g.active_scroll.is_some()); + // Wait for input only up to the next frame deadline so the loop wakes to // tick even with no keypresses. let timeout = FRAME.saturating_sub(last_tick.elapsed()); @@ -194,6 +199,19 @@ fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) -> ui.tick(dt, mode); } + // A scroll can be opened either by a script tick or by a player bump during + // input handling (bump reactions now resolve within try_move). Start its open + // animation once, whichever opened it — detected by the scroll appearing since + // the top of this frame while no animation is running. + if ui.active_animation.is_none() + && !scroll_active_before + && let Mode::Play(game) = &*mode + && let Some(scroll) = &game.active_scroll + { + let lines = scroll.lines.clone(); + ui.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines))); + } + // Apply any Play↔Edit switch a menu action requested this frame. apply_pending_mode(mode, ui); diff --git a/kiln-tui/src/ui.rs b/kiln-tui/src/ui.rs index cb9ce46..ab9b2bf 100644 --- a/kiln-tui/src/ui.rs +++ b/kiln-tui/src/ui.rs @@ -3,7 +3,7 @@ use crate::editor::EditorState; use crate::log::LogState; use crate::menu::{MenuCloseAnimation, MenuItem, MenuOpenAnimation, MenuState}; use crate::mode::{Mode, PendingMode}; -use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState}; +use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOverlayState}; use crate::transition::TransitionAnimation; use kiln_core::Board; use kiln_core::game::GameState; @@ -137,15 +137,12 @@ impl Ui { // scroll the instant its open animation finished. let input_mode = crate::input::current_input_mode(mode, self); match mode { - // Play: tick the game (only in Board mode), then check whether a scroll - // overlay appeared as a result of this tick. + // Play: tick the game only in Board mode (a scroll/menu overlay pauses + // it). The run loop starts the scroll open animation when one appears, + // whether from this tick or from a player bump during input. Mode::Play(game) => { if matches!(input_mode, crate::input::InputMode::Board) { game.tick(dt); - if let Some(scroll) = &game.active_scroll { - let lines = scroll.lines.clone(); - self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines))); - } } } // Edit: only the cursor blink advances; the game does not exist here. diff --git a/maps/start.toml b/maps/start.toml index 92d20d0..ca3dca8 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -39,7 +39,7 @@ fn init(me) { // 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(me, state, dt) { +fn tick(me, dt) { if !me.waiting { dance(me); } } fn dance(me) {