diff --git a/CLAUDE.md b/CLAUDE.md index 7ff3e18..25b7787 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`, `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, state, id)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`) and `state` (the world). 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(x, y)`, `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `add_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`. 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, state, id)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`) and `state` (the world). 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(x, y)`, `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`. 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 cb4ebd5..73f1738 100644 --- a/docs/script-api.md +++ b/docs/script-api.md @@ -67,11 +67,11 @@ fn bump(me, state, id) { Called when the **player walks onto** this object and the object is a *grab* thing (gems, hearts — `solid + grab`). Unlike `bump`, the player ends up on the object's cell; the hook typically adjusts a player stat and removes the object. It fires followed by an immediate resolve, so any `die()` / -`add_gems()` / `alter_health()` applies before the player's move returns. +`alter_gems()` / `alter_health()` applies before the player's move returns. ```rhai fn grab(me, state) { - add_gems(1); + alter_gems(1); die(); } ``` @@ -276,7 +276,7 @@ engine after the batch. The *subject* of a write is implicit and varies by funct | `send(target_id, fn_name [, arg])` | any object | 0 | Call a handler function on another object; optional string/number arg. | | `delay(secs)` | self | — | Insert an explicit pause; integer and float overloads. Adjacent delays merge. | | `now()` | self | — | Move the **most recently enqueued** action to the front of the queue. | -| `add_gems(n)` | player | 0 | Change the player's gem count (clamped at 0). | +| `alter_gems(n)` | player | 0 | Change the player's gem count (clamped at 0). | | `alter_health(dh)` | player | 0 | Change the player's health (clamped to `[0, max_health]`). | | `set_key(color, present)` | player | 0 | Give/take a key color (`"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`, `"orange"`, `"yellow"`, `"white"`). Unknown name logs and is ignored. | | `die()` | self | 0 | Remove the calling object from the board. | @@ -397,11 +397,11 @@ they are shape problems. ### Inconsistency — naming and conventions - **No consistent verb convention.** Setters by name (`set_tile`, `set_tag`, `set_fg`, `set_bg`, - `set_color`, `set_key`), delta verbs (`add_gems`, `alter_health`), and bare verbs (`move`, `die`, + `set_color`, `set_key`), delta verbs (`alter_gems`, `alter_health`), and bare verbs (`move`, `die`, `teleport`, `push`, `shift`, `scroll`, `say`, `log`) all coexist. Worse, the two "change by a - delta" operations use *different* verbs: `add_gems` vs `alter_health`. + delta" operations use *different* verbs: `alter_gems` vs `alter_health`. - **The implicit subject of a write is unpredictable.** `set_tile`/`set_fg`/`say`/`die` act on - **self**; `add_gems`/`alter_health`/`set_key` act on the **player**; `set_tag`/`send` act on an + **self**; `alter_gems`/`alter_health`/`set_key` act on the **player**; `set_tag`/`send` act on an **arbitrary target**; `push`/`shift`/`teleport` act on **cells**. The `set_*` prefix in particular means three different subjects. - **Reads are methods/getters on handles, but writes are free functions.** `me.blocked(dir)` reads, diff --git a/kiln-api.md b/kiln-api.md index 481f706..3ad785a 100644 --- a/kiln-api.md +++ b/kiln-api.md @@ -69,7 +69,7 @@ fn bump(id) { } fn grab() { - add_gems(1); + alter_gems(1); die(); } ``` @@ -254,7 +254,7 @@ fn tick(dt) { ]); } } -fn yes() { say("Brave soul!"); add_gems(5); } +fn yes() { say("Brave soul!"); alter_gems(5); } fn no() { say("Coward."); } ``` @@ -299,7 +299,7 @@ swap([[3, 3, 3, 4], [3, 4, 3, 3]]); | Call | Effect | |---|---| -| `add_gems(n)` | Add `n` to the player's gem count (negative subtracts; clamped at 0). | +| `alter_gems(n)` | Add `n` to the player's gem count (negative subtracts; clamped at 0). | | `die()` | Remove the calling object from the board. Zero cost. | --- diff --git a/kiln-core/CLAUDE.md b/kiln-core/CLAUDE.md index 1f68a7b..b878ea9 100644 --- a/kiln-core/CLAUDE.md +++ b/kiln-core/CLAUDE.md @@ -70,7 +70,7 @@ 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 `add_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(-1)` on any solid object walked into; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` + an immediate `resolve()` so its `die()`/`add_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, bumper)` pairs, 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(-1)` on any solid object walked into; 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, bumper)` pairs, 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. **`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). @@ -92,7 +92,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - 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, state, id)` (the bumper's `ObjectId`, or `-1` for the player), 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** (2/3/3/2). Driven by `run_init(state)` / `run_tick(state, dt)` / `run_bump(state, id, bumper)` / `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. - `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(x, y)`, `push(x, y, dir)`, `shift([[x, y], …])`, `add_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`. +- **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(x, y)`, `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()`). - **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. diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 54c3405..f69bb15 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -70,7 +70,7 @@ pub struct GameState { /// The game-global player state (health, gems, keys) — see [`Player`]. Not /// per-board: it persists across board transitions, unlike the per-board /// position in [`Board::player`](crate::board::Board::player). Scripts mutate - /// it via `add_gems`/`alter_health`/`set_key` and read a snapshot of it. + /// it via `alter_gems`/`alter_health`/`set_key` and read a snapshot of it. pub player: PlayerRef, } @@ -477,7 +477,7 @@ impl GameState { return; } // Fire the grab hook and resolve it immediately so the grabbed thing's - // die()/add_gems() apply now — no player+object overlap survives this call. + // die()/alter_gems() apply now — no player+object overlap survives this call. if let Some(id) = grabbed { self.scripts.run_grab(id); self.resolve(); diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index d56acd8..2b6c27d 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -28,7 +28,7 @@ //! `move(dir)`, `delay(secs)`, `now()`, `set_tile(n)`, `log(msg)`, `say(msg)`, //! `set_fg(fg)`, `set_bg(bg)`, `set_color(fg, bg)`, `set_tag(target, tag, present)`, //! `send(target_id, fn_name [, arg])`, `teleport(x, y)`, `push(x, y, dir)`, -//! `shift([[x, y], …])`, `add_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()` +//! `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()` use crate::action::{Action, BoardAction, ScrollLine, SendArg, MOVE_COST}; use crate::game::SAY_DURATION; @@ -377,9 +377,9 @@ fn register_write_api(engine: &mut Engine, board: BoardRef) { emit(&b, source_of(&ctx), Action::SetTile(tile as u32)); }); - // add_gems(n): change the player's gem count (negative subtracts; clamped at 0). + // alter_gems(n): change the player's gem count (negative subtracts; clamped at 0). let b = board.clone(); - engine.register_fn("add_gems", move |ctx: NativeCallContext, n: i64| { + engine.register_fn("alter_gems", move |ctx: NativeCallContext, n: i64| { emit(&b, source_of(&ctx), Action::AddGems(n)); }); diff --git a/kiln-core/src/scripts/gem.rhai b/kiln-core/src/scripts/gem.rhai index be33f7b..2ba1770 100644 --- a/kiln-core/src/scripts/gem.rhai +++ b/kiln-core/src/scripts/gem.rhai @@ -4,6 +4,6 @@ // player) fires this `grab()` hook instead of blocking. We bump the player's gem // count and remove ourselves from the board. fn grab(me) { - add_gems(1); + alter_gems(1); die(); }