Files

125 lines
40 KiB
Markdown
Raw Permalink Normal View History

2026-06-21 11:54:19 -05:00
# kiln-core
Module reference for the `kiln-core` crate — the engine: all core game types
(`Board`, `Glyph`, `Archetype`, `GameState`, …) plus `.toml` map-file
load/save. No rendering or UI; every front-end depends on it. See the
repository-root `CLAUDE.md` for project-wide guidance, code style, the world
file format, and key design decisions.
The core types that were formerly monolithic in `game.rs` are now split into focused modules. `game.rs` contains only `GameState`; the data types live in their own files.
**`kiln-core/src/glyph.rs`** — per-cell visual:
- `Glyph` (`Copy`, `Eq`, `Hash`) — `tile: u32` (tilesheet index), `fg/bg: Rgba8`. `Glyph::player()` is a `const fn` (tile 64 `@`, white on dark blue); all other glyphs come from map files. `Hash` is hand-implemented via packed `u32` representations so it stays in sync with `Eq`.
**`kiln-core/src/cp437.rs`** — tile-index → character mapping (`pub`):
- `CP437: [char; 256]` — full code-page-437 table (graphic glyphs for 0x000x1F, box-drawing for 0xB00xDF, etc.). `tile_to_char(tile: u32) -> char` indexes it for `tile < 256`; falls back to `char::from_u32` then a blank space. Unit-tested. Lives in core (not a front-end) because it is the *meaning* of a tile index under the default font; used by kiln-tui's renderer (`render.rs`) and kiln-ui's glyph picker alike.
**`kiln-core/src/colors.rs`** — the named color palette (`pub`):
- `NAMED_COLORS: [(&str, Rgba8); 16]` — the 16 EGA/VGA palette colors as `(name, color)` pairs in palette order (`Black`, `Blue`, …, `White`). The **single source of truth**: `script::register_global_constants` builds the Rhai `Black`/`Blue`/… `"#RRGGBB"` constants from it, and kiln-ui's glyph picker builds its named-swatch strips from it, so the two never drift.
**`kiln-core/src/archetype.rs`** — element taxonomy:
2026-06-23 01:08:01 -05:00
- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Builtin(Builtin, &'static str)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` checks the `Builtin` registry first (via `Builtin::from_name`), then the hard-coded terrain names; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
2026-06-23 22:52:25 -05:00
- `Builtin` (`Copy`, `PartialEq`, `Hash`) — the family enum for all script-backed archetypes: `Gem`, `Heart`, `Pusher`, `Spinner`, `Key`. Generated by the `builtins!` macro (see below) along with all its methods. `Archetype::Builtin(family, alias)` carries both the family (selects the behavior and script source) and the specific alias matched during parsing (e.g. `"pusher_north"` or `"spinner_cw"`); the alias drives the per-alias glyph, the `BUILTIN_<alias>` tag on the expanded object, and the compile-cache key.
2026-07-08 13:21:27 -05:00
- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable), `Transporter` (`"transporter_north|south|east|west"` → 94/118/41/40 `^`/`v`/`)`/`(`; cyan, solid + **see-through** (`opaque: false`) + unpushable — animates a 4-frame loop and, on a front bump, moves whatever solid presses into its entrance — the player, an object, or a **pushed crate** — via a coordinate `shift`, either onto the cell just past it or, if that is blocked, out the far side of the nearest paired opposite-facing transporter). All aliases in a family share one Rhai script; the script reads `me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality.
2026-06-21 11:54:19 -05:00
2026-06-23 01:08:01 -05:00
**`kiln-core/src/builtin_scripts.rs`** — tag helpers for script-backed archetypes (`pub(crate)`):
- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. `expand_builtin_archetypes` tags each expanded object with `BUILTIN_<alias>` (e.g. `"BUILTIN_pusher_east"`) and also uses this string as the object's `script_name` compile-cache key (so each alias gets its own cached AST, though the Rhai source is the same for all aliases in a family). The script reads its tag to determine per-instance direction. `map_file::save` uses `archetype_from_builtin_tag` to collapse the object back into its archetype keyword so maps round-trip. The script sources and behavior definitions now live in `archetype.rs` via the `builtins!` macro (via `include_str!` of `scripts/*.rhai`); this file has only the three tag helpers.
2026-06-21 11:54:19 -05:00
**`kiln-core/src/utils.rs`** — shared primitive types (`pub(crate)`):
- `Pushable` (`No`/`Any`/`Horizontal`/`Vertical`) — which directions a solid may be pushed. `allows(dir) -> bool`.
2026-06-23 22:52:25 -05:00
- `Behavior` — plain data struct from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`, `grab: bool` (the player walking onto a `solid + grab` thing isn't blocked — it moves onto it and the thing's `grab()` hook fires; this is the *only* grab trigger; `Gem` and `Heart` both set the flag).
2026-06-21 11:54:19 -05:00
- `ObjectId = u32` — stable identifier for board objects.
2026-07-10 23:16:28 -05:00
- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Terrain { glyph, arch }` (a grid cell), or `Object(ObjectId)`. `place(board, x, y)` relocates it (terrain rewrites the destination grid cell). No layer index — the board is one grid.
2026-06-25 19:16:46 -05:00
- `PlayerPos { x: i32, y: i32 }` — the player's current position on a board (held as `Board::player`). The broader player *state* (health, gems, keys) lives in [`player::Player`], not here.
2026-07-10 23:16:28 -05:00
- `PortalDef { name, x, y, target_map, target_entry }` — parsed from map files; runtime-wired (portals switch boards). No `z`.
- `LogSink(Rc<RefCell<Vec<LogLine>>>)` — the shared, immediate log channel handed to every `Registerable::register` and the write API. `line(LogLine)` / `error(String)` push straight onto it during hook execution (so script `log()` output and errors are *not* paced by the object's action queue); `take()` drains it. `GameState::drain_log` empties it into `GameState::log` each frame.
2026-06-21 11:54:19 -05:00
**`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
2026-07-10 23:16:28 -05:00
- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `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<String>`, `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<String>`, `name: Option<String>`. A **trigger** is just an `ObjectDef` that is non-solid, non-opaque, non-pushable, glyphless (transparent glyph) and scripted (authored via `[[triggers]]`). 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::default_glyph()` returns tile 63 (`?`) yellow on black.
2026-06-21 11:54:19 -05:00
2026-07-10 23:16:28 -05:00
**`kiln-core/src/layer.rs`** — the board grid (palette + char map load unit; file/type name kept for history):
- `GridCell = (Glyph, Archetype)` (`pub(crate)`) — one grid cell: its visual and its behavioral class. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the floor/decoration beneath shows through; solidity comes from the archetype, independent of transparency. (There is no longer a `Layer` type — the board is a single `Vec<GridCell>`.)
- `GridData { content, fill, sparse, palette: HashMap<String, PaletteEntry> }` — serde for the single `[grid]` block. The char map comes from exactly one of three optional fields (precedence `content``fill``sparse`; none ⇒ all spaces): `content` (a multi-line grid string), `fill` (one char filling the whole grid), or `sparse` (a `Vec<SparseCell>` of `{ x, y, ch }` over an otherwise all-spaces grid). Only `content` can mismatch the board dims (hard error); `fill`/`sparse` are always exactly sized (a bad `fill`/`ch` or out-of-bounds `sparse` cell is a nonfatal error). `PaletteEntry` is a single flat struct with a `kind: String` discriminator plus all-optional fields (`tile`, `fg`, `bg`, `solid`, `opaque`, `pushable`, `script_name`, `tags`, `name`, `target_map`, `target_entry`); only the fields relevant to the kind are read. (`floor` is **not** a grid kind — the floor is a board attribute; see `floor.rs`/`map_file.rs`.)
- `build_grid(data, w, h, &mut Vec<LogLine>) -> Result<(Vec<GridCell>, Vec<Placement>), String>` — resolves the palette (via `resolve_entry`), validates the grid dims (the only hard `Err`), and walks the grid building the cell vec plus a `Vec<Placement>` (`Object(ObjectTemplate, x, y)` / `Portal(PortalTemplate, x, y)` / `Player(x, y)`) for the map loader to resolve.
- `resolve_entry` maps `kind``Resolved`: `empty` → transparent cell; `object`/`portal`/`player` → a placement; any other string → `Archetype::try_from` (`Ok` → terrain cell; `Err` → visible `ErrorBlock` + logged error). A `portal` missing `name`/`target_map`/`target_entry` is dropped to a transparent cell with an error.
2026-06-21 11:54:19 -05:00
**`kiln-core/src/board.rs`** — the board data type:
2026-07-10 23:16:28 -05:00
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `grid: Vec<(Glyph, Archetype)>` (the single row-major cell grid; `pub(crate)`), `floor: Floor` (the cosmetic floor attribute — blank / fixed glyph / biome; `pub(crate)`), `decorations: Vec<Decoration>` (`pub(crate)`), `player: Player`, `objects: BTreeMap<ObjectId, ObjectDef>`, `next_object_id: ObjectId`, `portals: Vec<PortalDef>`, `board_script_name: Option<String>`, `load_errors: Vec<LogLine>` (`pub(crate)`), `registry: HashMap<String, RegistryValue>`. Scripts are **not** stored on `Board` — they live in [`World::scripts`].
- `Decoration { x, y, glyph, archetype }` (`pub`) — a non-solid `(glyph, archetype)` placed off the grid, drawn only where the grid cell is empty. Normally empty; populated by save files (a runtime solid can end up over a non-solid, which is recorded here). A solid archetype is rejected at load.
- `get(x, y) -> &(Glyph, Archetype)` / `get_mut(x, y)` — grid cell access (panics OOB). (No `z` / `layer_count` — the board is one grid.)
- `glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`, by fixed precedence: the player (`Glyph::player()`); then an object on the cell (a solid object always, else the first visible non-solid); then the grid cell (a solid always, else a non-transparent glyph); then a portal; then a `decoration` (only reached because the grid cell was empty); then `floor.glyph_at(x, y, width)`; then the canonical black `Empty` glyph. Front-ends call this per-cell instead of managing a separate player overlay.
- `solid_at(x, y) -> Option<Solid>` — the cell's single solid occupant (player checked first, then a solid object, then the grid cell's archetype if solid).
2026-06-21 11:54:19 -05:00
- `is_passable(x, y)` — convenience inverse of `solid_at`.
- `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
2026-07-08 13:21:27 -05:00
- `bump_target(x, y, dir) -> Option<ObjectId>` — the object a move **into** `(x, y)` heading `dir` bumps: the solid object in the target cell, or the one at the end of a chain of pushed crates (walked through in `dir`). Open space, the player, or a wall yield `None`. This is what lets a plain crate — not just an object or the player — trigger a `bump`. Used by `GameState::try_move` / `step_object`; the caller supplies the came-from direction as `dir.opposite()`.
2026-07-05 23:41:19 -05:00
- `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The **player** counts as a blocker for this check. A Rust-side companion read to `apply_shift`; not itself exposed to scripts.
2026-07-11 11:47:45 -05:00
- `push(x, y, dir) -> Vec<(usize, usize)>` — mutating: shoves the chain one step, leaving a transparent cell behind (so the floor is revealed). A pushed solid moves within the grid. **Returns the destination cells** each shoved solid moved into (empty when nothing moved), so callers can fire `enter` on any non-solid a pushed solid landed on.
- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)`, `non_solid_object_ids_at(x, y)` (the non-solid objects on a cell — the `enter`-hook targets) — object add/remove + queries.
2026-07-05 23:41:19 -05:00
- `grab_object_at(x, y) -> Option<ObjectId>` — a solid `grab` object on the cell (the player-walks-onto-it case). `GameState::try_move` uses it to fire `grab()` instead of bumping when the player steps onto a grab thing. Grab is **only** a player-movement event: a grab thing pushed/shifted into the player is treated as an ordinary solid (no special-case). (`remove_object` deletes the object and its `ObjQueue` from the `objects` map; a live `ScriptHost` keeps an unused `Scope` for that id until it is rebuilt — harmless, since hook dispatch iterates the board's *current* ids.)
2026-07-11 11:47:45 -05:00
- `clear_solid(x, y)` — replaces the grid cell's solid terrain (if any) with a transparent `Empty`, revealing the floor. `apply_shift(cells: &[(i32, i32)]) -> ShiftOutcome` — rotates the solids occupying a ring of cells one step: each cell's solid moves to the next coordinate in the list, and the last wraps back to the first. Backs the script `shift()` fn. A cell is **immobile** if its solid is unpushable, or is an `HCrate`/`VCrate` asked to move along its forbidden axis; immobility **cascades backward** along the ring, so a blocked run stays put while the free tail still rotates. Movable cells are cleared, then each mover is `place`d at its target (a displaced object is despawned via `remove_object`). Any out-of-bounds coordinate **rejects the whole shift**. The player rotates like any other solid. Returns a `ShiftOutcome { errors: Vec<LogLine>, moves: Vec<((i64,i64),(i64,i64))> }` — the error lines to log plus each `(from, to)` relocation performed, so the caller can fire `enter` at each destination (direction best-effort via `Direction::from_delta`, since a ring may rotate non-adjacent cells).
2026-07-10 23:16:28 -05:00
- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving the floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the grid cell; `Empty` erases — removes every object plus the grid cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`.
- `expand_builtin_archetypes()` — the **single** expansion point: replaces every `Archetype::Builtin(b, alias)` grid cell with the scripted object it expands to. For each such cell: vacates the grid cell (→ transparent `Empty`), calls `b.script()` for the embedded source, uses `builtin_tag(arch)` as both the `BUILTIN_<alias>` tag and the `script_name` compile-cache key, and copies the cell's glyph (so palette overrides survive). The object's `pushable`/`grab` flags come from `b.behavior()`. Idempotent (vacated cells become `Archetype::Empty`, so a second pass finds nothing). Called by `TryFrom<MapFile> for Board` after cross-cell validation (disk loads) and again by the editor's `playtest()` on the `World::deep_clone`. Builtin objects get ids after hand-placed ones (reading order among themselves).
2026-06-21 11:54:19 -05:00
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
2026-06-25 19:16:46 -05:00
**`kiln-core/src/player.rs`** — game-global player state (`pub`):
- `Player { health: i64, max_health: i64, keys: Keyring, gems: i64 }` — the player's persistent stats, owned by `GameState::player` (separate from the per-board `PlayerPos` position in `utils.rs`). `Default` starts `health = max_health = 5`, `gems = 0`, no keys. `alter_gems(delta) -> bool` adds to `gems` but refuses (returns `false`, no change) if it would go negative. `alter_health(delta)` adds to `health` clamped to `[0, max_health]`. A `Copy` snapshot is handed to each script hook and exposed to Rhai (read-only) as the `Player` constant.
**`kiln-core/src/keys.rs`** — key inventory and colors (`pub`):
- `KeyType` (`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Cyan`/`Purple`/`White`) — the eight key colors. `glyph() -> Glyph` returns the key tile (12, `♀`) in that color's fg — the single source of truth for key colors, used by `Keyring::colors` and by kiln-tui's editor menu. (Moved here from `game.rs`; `archetype.rs`'s `Key` builtin aliases must match these glyphs — see its `key_aliases_have_distinct_fg_colors` test.)
- `Keyring { red, orange, yellow, green, blue, cyan, purple, white: bool }` — the player's key inventory (one bool per color), held by `Player::keys`. `set_by_name(name, value) -> bool` sets a slot by color name (returns `false` for an unknown name); `colors() -> [(bool, Rgba8); 8]` lists all eight in display order paired with their render color.
2026-06-21 11:54:19 -05:00
**`kiln-core/src/game.rs`** — game-loop logic only:
- `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<ScrollLine> }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; 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`.
2026-07-11 12:04:33 -05:00
- `GameState` — owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, 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>` / `board_mut() -> RefMut<Board>` (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<RefCell<Board>>` 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; it also fires `enter(dir_from)` on any **non-solid** object under the player's new cell or under a cell a shoved crate landed on; 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`). `tick` is called on an object **only when its output queue is empty**; a non-empty queue (pending actions/`Delay` from an earlier tick) means the queue is just drained by `dt` and the script is not re-run until it empties. The workhorse is `apply_actions(Vec<BoardAction>) -> Events`: two-phase (mutate the board collecting `(bumped, dir_from)` and `(entered, dir_from)` pairs, drop the borrow, then apply the player-stat / bubble / scroll changes), applying each `Action` (`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`/`enter`/`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. `Events` carries three reaction lists — `bumps`, `enters`, `sends`; the `Move`/`Push`/`Teleport`/`Shift` arms populate `enters` for every non-solid a solid relocated onto (via `Board::non_solid_object_ids_at`), the direction exact for a one-cell move/push and best-effort (`Direction::from_delta`) for a teleport/shift. `step_object` returns a `StepOutcome { bumped, entered }` (a non-soli
2026-06-21 11:54:19 -05:00
2026-07-05 23:41:19 -05:00
**`kiln-core/src/action.rs`** — the `Action` enum and its supporting types:
2026-06-21 11:54:19 -05:00
- `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).
2026-07-05 23:41:19 -05:00
- `SendArg` — the optional argument carried by a `Send` action / `send()` call: `None`/`Int`/`Float`/`String`, with `From<Dynamic>`/`Into<Dynamic>` conversions.
2026-07-10 23:16:28 -05:00
- `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)`, `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<ScrollLine>)`, `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).
2026-07-10 01:20:26 -05:00
- `BoardAction { source, action }` — an `Action` drained from an object's queue, tagged with the `ObjectId` that issued it; `GameState` applies a `Vec<BoardAction>` per object.
2026-06-21 11:54:19 -05:00
2026-07-10 23:16:28 -05:00
**`kiln-core/src/floor.rs`** — the board floor attribute + procedural generators:
- `Floor` (`pub`) — a board's cosmetic floor, drawn beneath everything (replaces the old floor *layer*): `Blank` (the canonical black empty shows), `Fixed(Glyph)` (one glyph tiled across the board), or `Biome { generator, glyphs }` (a procedural texture — keeps its `generator` so save re-emits the name, plus a per-cell `glyphs` buffer pre-rolled once at load from `FLOOR_SEED`). `Floor::biome(gen, w, h)` builds and pre-rolls a biome; `Floor::glyph_at(x, y, width) -> Option<Glyph>` is the per-cell lookup used by `Board::glyph_at`; `Default` is `Blank`. Resolved from the map-file `floor = { … }` attribute in `map_file::FloorSpec::resolve`.
- `FloorGenerator` (`Grass`/`Dirt`/`Stone`) — procedural textures differing only in color scheme and texture-char probability. `from_name(&str)` parses the map-file name and `name()` is its inverse (for save); `generate(&mut StdRand)` picks a dark, low-saturation ground color (green/brown/gray) and, with the generator's probability, scatters a lighter texture char (grass `, . \` '`; dirt `. : , ;`; stone `. ,`). Uses `tinyrand` (already a workspace dep, WASM-safe). `FLOOR_SEED` (`pub(crate)`) seeds one `StdRand` per biome build, so floors are deterministic/testable and depend only on generator + dimensions.
2026-06-21 11:54:19 -05:00
**`kiln-core/src/log.rs`** — styled log messages:
- `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — 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.
2026-07-05 23:41:19 -05:00
**`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).
2026-07-10 23:16:28 -05:00
- `Registerable` trait — `fn register(engine, log_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`.
2026-07-11 11:47:45 -05:00
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap<String, CompiledScript>`), one persistent `Scope` per scripted object (`HashMap<ObjectId, Scope>`), and the shared `LogSink`. (There is no shared board queue: each hook call drains into a local `Vec<BoardAction>` that it returns to `GameState`.) Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: 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`/`has_enter`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the `LogSink`; runs nothing.
2026-07-11 12:04:33 -05:00
- 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), `enter(me, dir)` (the non-solid counterpart to `bump`: fired on a non-solid object when a solid *relocates onto* its cell — a player/object step, a pushed crate, a `teleport`, or a `shift` — with `dir` the side the entrant came from, best-effort for teleport/shift), 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 and enter are 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_enter(id, dir)` / `run_grab(id)` / `run_send(id, fn, arg)`, all funneling through `run_hook_on_one` and **returning the `Vec<BoardAction>` 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. The `Tick` hook is **gated on an empty queue** (`hook != Hook::Tick || info.queue.is_empty()`), so an object with pending actions/`Delay` from an earlier tick isn't re-ticked; all other hooks fire whenever defined. After the call — **whether or not the hook was called** — it always drains the object's queue, so a delay still advances on an object that has no `tick` (or whose `tick` was skipped this frame). Runtime errors go to the shared `LogSink` (drained to the log), not fatal.
2026-07-05 23:41:19 -05:00
- `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.
2026-07-10 23:16:28 -05:00
- **Write API** (`register_write_api`) — free functions that (mostly) 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)`, `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()`. **`log(s)` is the exception** — it is *not* an `Action`; it writes a line straight to the shared `LogSink`, so it surfaces immediately regardless of the object's queued delays (the write closure takes a `LogSink` clone). `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 immediately to the `LogSink`) and rotates that ring of cells via `Board::apply_shift`.
- **Queue draining:** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(&mut Vec<BoardAction>, 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. `log()` output and errors bypass the queue entirely via the shared `LogSink` (`take_logs()`), so they are never paced by a `Delay`.
2026-07-05 23:41:19 -05:00
- **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<String, RegistryValue>`, 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.
2026-06-21 11:54:19 -05:00
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
2026-07-05 23:41:19 -05:00
**`kiln-core/src/api/`** — the Rhai **script API**: the types scripts actually touch, each implementing `script::Registerable`. Split out of `script.rs` so the host (engine/queues/dispatch) and the script-facing surface can evolve separately. (Author-facing reference: [`docs/script-api.md`](../docs/script-api.md).)
- `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<RefCell<Board>>` (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`).
2026-07-10 01:20:26 -05:00
- `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<BoardAction>, dt)` moves the underlying object's ready queued actions into the target `Vec`.
- `api::queue``ObjQueue(Rc<RefCell<VecDeque<Action>>>)` (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<BoardAction>`), `waiting` (front is a `Delay`).
2026-06-21 11:54:19 -05:00
2026-07-10 23:16:28 -05:00
**`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the grid work lives in `layer.rs`):
- `MapFile { map: MapHeader, grid: GridData, triggers: Vec<TriggerSpec>, decorations: Vec<DecorationSpec> }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `floor: FloorSpec` and `board_script_name`; **no** `player_start` — the player is a `kind = "player"` grid char), the single `[grid]`, and the off-grid `[[triggers]]`/`[[decorations]]` lists. Does **not** include scripts (those live in `World::scripts`).
- `FloorSpec { generator?, tile?, fg?, bg? }``Floor` via `resolve` (generator → biome + pre-roll; any of tile/fg/bg → fixed glyph; empty → blank; unknown generator → blank + error). `TriggerSpec { x, y, script_name, name?, tags? }` → a non-solid glyphless scripted `ObjectDef`. `DecorationSpec { x, y, kind, tile?, fg?, bg? }` → a `Decoration` (a solid archetype is rejected).
2026-06-21 11:54:19 -05:00
- `TileIndex` (`pub`) — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably. `parse_color`/`color_to_hex` (`pub(crate)`) are shared with `layer.rs`.
2026-07-10 23:16:28 -05:00
- `impl TryFrom<MapFile> for Board` — the load conversion: builds the single grid via `layer::build_grid` (collecting object/portal/player placements), resolves the floor, then runs the cross-cell validations. **Best-effort/nonfatal**: only a grid-dimension mismatch returns `Err`; everything else is recorded on `Board::load_errors` (see `Board::is_valid`) — the player must appear exactly once (missing → `(0,0)`; multiple → first) and *wins* its cell; one solid per cell (a conflicting solid object is dropped); object names board-unique (a dup is cleared, via `claim_name`), portal names unique (a dup is dropped). Then `[[triggers]]` load as non-solid glyphless objects (after the grid objects) and `[[decorations]]` as off-grid non-solids. Finally it calls `Board::expand_builtin_archetypes` to turn script-backed archetype cells into their scripted objects.
- `impl From<&Board> for MapFile` — save: emits the single `[grid]` (deduping each unique `(Glyph, Archetype)` to a palette char; objects to `kind = "object"`, portals, player), **except** trigger objects (`is_trigger`: non-solid + transparent glyph + scripted + non-builtin) which go to `[[triggers]]`, and decorations to `[[decorations]]`. The floor is written back via `floor_to_spec` — a biome re-emits its `generator` name (procedural floors now round-trip), a fixed glyph its tile/fg/bg.
2026-06-21 11:54:19 -05:00
- `pub fn load(path: &str) -> Result<Board, …>` — reads a single-board `.toml` file. Production code uses `world::load` instead.
- `pub fn save(board: &Board, path: &Path) -> Result<(), …>` — serializes `Board` back to a single-board TOML file.
**`kiln-core/src/world.rs`** — world type and world-file loading:
- `World` — the runtime representation of a `.toml` world file: `name: String`, `start: String` (key of the starting board), `scripts: HashMap<String, String>` (named Rhai source, shared across all boards), `boards: HashMap<String, Rc<RefCell<Board>>>` (all boards, always present). Every board is wrapped in `Rc<RefCell<Board>>` so multiple shared refs can coexist — `GameState` clones the active board's `Rc` for its `ScriptHost`, and all boards remain in `world.boards` at all times (no board is ever "removed" when active).
- `pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>>` — reads a world `.toml` file, converts each `[boards.NAME.*]` subtable via `TryFrom<MapFile> for Board`, wraps each in `Rc::new(RefCell::new(...))`, and validates that `world.start` matches a board key.
- `pub fn deep_clone(&self) -> World` — a **fully independent** copy: rebuilds every board into a *fresh* `Rc<RefCell<Board>>` (`Board`/`Layer`/`ObjectDef` derive `Clone`) rather than sharing the `Rc`s. An explicit method, not a `Clone` impl, since shallow `Rc`-sharing would be a footgun. Used by the editor's playtest to run a game against an isolated world so play mutations never touch the boards being edited.