diff --git a/CLAUDE.md b/CLAUDE.md index 3e7f23e..030cf93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `ObjectDef` — a scripted tile: `x`, `y`, `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option`, `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. `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black. **`kiln-core/src/board.rs`** — the board data type: -- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; `pub(crate)`), `floor: Vec` + `floor_spec: Option` (visual floor layer; `pub(crate)`), `player: Player`, `objects: BTreeMap`, `next_object_id: ObjectId`, `portals: Vec`, `font: Option`, `zoom: u32` (integer tile scale factor), `scripts: HashMap` (named Rhai source text; script name → source), `board_script_name: Option` (name of a board-level script, if any), `load_errors: Vec` (`pub(crate)`). +- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; `pub(crate)`), `floor: Vec` + `floor_spec: Option` (visual floor layer; `pub(crate)`), `player: Player`, `objects: BTreeMap`, `next_object_id: ObjectId`, `portals: Vec`, `font: Option`, `zoom: u32` (integer tile scale factor), `board_script_name: Option` (name of a board-level script in the world script pool, if any), `load_errors: Vec` (`pub(crate)`). Scripts are **not** stored on `Board` — they live in [`World::scripts`]. - `get(x, y) -> &(Glyph, Archetype)` / `get_mut` — direct cell access (panics OOB). - `glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`. Priority: player > solid object > non-solid object with nonzero tile > non-Empty grid archetype > floor. **Includes the player** — `glyph_at` at the player's cell returns `Glyph::player()`. An `Empty` cell's own glyph is always ignored (floor supersedes it). Front-ends call this per-cell instead of managing a separate player overlay. - `solid_at(x, y) -> Option` — the cell's single solid occupant (player checked first, then objects, then grid archetype). @@ -85,7 +85,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` — holds `board: Rc>`, `log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, and `pub active_scroll: Option`. Front-ends reach the board through `board() -> Ref` / `board_mut() -> RefMut`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into. `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` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`). 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`. 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. `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` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`). 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/floor.rs`** — the visual floor layer: - The floor is a cosmetic per-cell background drawn wherever a cell would render as `Archetype::Empty` (it is *how* `Empty` is drawn; the cell's own `Empty` glyph is ignored). Computed once at load and cached on `Board::floor`, so there is no per-frame cost / flicker; the raw `FloorSpec` is also kept (`Board::floor_spec`) so `map_file::save` round-trips it. @@ -97,7 +97,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `LogSpan { text, fg: Option, bg: Option }` and `LogLine { spans: Vec }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts a `LogLine` to its own styled text at render time. **`kiln-core/src/script.rs`** — Rhai scripting runtime: -- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc>)` (registers the API, compiles scripts, reports compile/unknown-script failures onto the error sink; runs nothing). +- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc>, &HashMap)`: the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool (scripts are compiled from here, not from the board itself). Reports compile/unknown-script failures onto the error sink; runs nothing. - Lifecycle hooks per object: `init()` (zero-arg), `tick(dt)` (elapsed seconds as `f64`), and `bump(id)` (the bumper's `ObjectId`, or `-1` for the player), all optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)` / `run_bump(object_id, bumper)`; runtime errors go to the error sink (drained to the log), not fatal. - **Reads (direct):** read getters (`player_x`, `player_y`, `width`, `height`) are registered on `Rc>` (the `BoardRef` alias, exposed to Rhai under the type name `Board`) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant `Board`. `blocked(dir) -> bool` is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its *own* move, which is pumped only after its script returns). `has_tag(s) -> bool` and `get_tags() -> Array` read the calling object's tag set; `objects_with_tag(s) -> Array` returns all object ids carrying that tag. `my_name() -> String` returns the calling object's name (or `""` if unnamed); `object_id_for_name(s) -> i64` looks up an object by name and returns its id, or `0` if not found. Each getter briefly borrows the shared `Board`. - **Actions & rate limiting (two-tier queues):** host fns `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, `scroll(lines)` append an `Action` (`Move`/`SetTile`/`Log`/`Say`/`Scroll`; `MOVE_COST = 0.25` s for `Move`, else 0) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap`). `scroll(lines)` takes a Rhai array where each element is a string (text line) or a two-element array `[choice_key, display_text]` (selectable choice). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`). @@ -106,14 +106,18 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui. - **Sender identity uses stable ids:** `BoardAction.source`, `ObjectRuntime.object_id`, and the `QueueMap` keys are all the object's `ObjectId` (matching `Board::objects`' `BTreeMap` keys), so they survive object spawn/destroy/reorder. The per-call tag carries the id as an `i64` (id 0 — never valid — is the no-source fallback). `ScriptHost::objects` is still a `Vec` built by iterating the board map, so it stays in ascending-id order. -**`kiln-core/src/map_file.rs`** — map file loading: -- `MapFile` and friends — serde `Deserialize` types for TOML map files +**`kiln-core/src/map_file.rs`** — per-board serde types and single-board load/save: +- `MapFile` and friends — serde `Deserialize`/`Serialize` types for the per-board portion of a `.toml` file. Does **not** include scripts (those live in `World::scripts`, not on `Board`). - `TileIndex` — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably - `PlayerStart` — `#[serde(untagged)]` enum (`Coord([i32;2])` | `Char(char)`); `player_start = [x, y]` or `player_start = "@"` (locate that char in the grid). Same dual-form trick as `TileIndex` - `FontHeader` — deserializes the optional `[font]` section (`path`, `tile_w`, `tile_h`) - `impl TryFrom for Board` — the single load conversion. **Best-effort/nonfatal**: only a grid-dimension mismatch returns `Err`; every other problem (unknown archetype/grid char → `ErrorBlock`; object/player placement-char issues; one-solid-per-cell conflicts) is recovered and recorded on `Board::load_errors` (see `Board::is_valid`). Resolves the player (coord or `@` char, first-occurrence, falling back to `(0,0)`) and lets it *win* its cell; places objects by `x`/`y` or `palette` char -- `pub fn load(path: &str) -> Result>` — reads and converts a `.toml` map file -- `pub fn save(board: &Board, path: &Path) -> Result<(), Box>` — serializes `Board` back to TOML and writes to disk +- `pub fn load(path: &str) -> Result>` — reads a single-board `.toml` file (legacy format). Production code uses `world::load` instead. +- `pub fn save(board: &Board, path: &Path) -> Result<(), Box>` — 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` (named Rhai source, shared across all boards), `boards: HashMap>>` (all boards, always present). Every board is wrapped in `Rc>` 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>` — reads a world `.toml` file, converts each `[boards.NAME.*]` subtable via `TryFrom for Board`, wraps each in `Rc::new(RefCell::new(...))`, and validates that `world.start` matches a board key. ### kiln-tui modules @@ -122,7 +126,7 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint **Drawing rule: prefer ratatui primitives.** Before writing manual cell-by-cell buffer code, check whether a `Block`, `Paragraph`, `Layout`, `Line::centered()`, or other ratatui widget/method achieves the same result. If a small design change (e.g. fixed vs. content-driven sizing) would unlock a ratatui-native approach, raise it as a question rather than writing manual code. The documented exceptions are: the background dimming pass in `ScrollOverlayWidget` (no ratatui primitive for "tint the whole buffer"), and the tail characters in `SpeechBubblesWidget` (`speech.rs`): the tail-join `┬` on the bottom border and the `│` column drawn below the box down to the source object. **`kiln-tui/src/main.rs`** — entry point and play loop: -- Parses a single positional arg (the map path); prints usage and exits non-zero if missing. Loads the board via `kiln_core::map_file::load` *before* touching the terminal so load errors print to a normal screen. +- Parses a single positional arg (the world file path); prints usage and exits non-zero if missing. Loads the world via `kiln_core::world::load` *before* touching the terminal so load errors print to a normal screen, then calls `GameState::from_world(world)` to start on the world's designated start board. - `ratatui::init()` → detect `TerminalCaps` → push Kitty flags when supported → `run` loop → pop Kitty flags → `ratatui::restore()`. - `run` is a ~30 FPS real-time loop: it `event::poll`s only until the next frame deadline (so input stays responsive) and otherwise wakes to advance the game by the real elapsed `dt` via `GameState::tick`. It acts on `Press` **and** `Repeat` key events (so holding a key moves continuously) and ignores `Release` (which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys move the player via `GameState::try_move`; `l` toggles the log panel; `q`/`Esc` quit. Mouse capture is enabled so the log panel scrolls (wheel) and resizes (drag the divider). `game.run_init()` runs object `init()` hooks after the board is loaded and before the loop. When `game.active_scroll` is `Some`, all input is redirected to scroll navigation (Up/Down to scroll, letter keys for choices, Esc to dismiss if no choices); `game.tick()` is skipped until the overlay is closed. - `draw` wraps the board in a bordered `Block` titled with the board name and controls. When the log panel is closed, the latest log line is previewed in the bottom-left border after a `[l]og:` label; when open, a bottom panel lists the log newest-first (`render::logline_to_line` converts each `LogLine`). The scroll overlay is drawn last (above everything) by `render::draw_scroll_overlay`. @@ -222,60 +226,79 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint - `show(ctx, open, glyph, board_cells, font)` — takes `open: &mut bool` and `glyph: &mut Glyph` directly; no dependency on `EditorState` - Three sections: board palette (unique glyphs deduped via `HashSet`, click to select), FG/BG color pickers, scrollable tile grid (all tiles in the active font at `tile_w × tile_h` per cell) -### Map file format (`maps/*.toml`) +### World file format (`maps/*.toml`) -XPM-inspired: a `[palette]` maps single characters to `(Glyph, Archetype)` definitions; `[grid] content` is a TOML multi-line string where each character indexes the palette. +Each `.toml` file is a **world**: a named collection of boards plus a shared script pool. The `[world]` header names the world and designates the starting board. Scripts live once at the top level and are referenced by name from any board's objects. Each board lives under `[boards.NAME.*]` where `NAME` is the board's identifier string. -**Keep this example in sync with `map_file.rs` whenever the format changes.** +**Keep this example in sync with `world.rs` / `map_file.rs` whenever the format changes.** ```toml -[map] -name = "Room Name" +[world] +name = "My World" +start = "room1" # key of the starting board + +# Named Rhai scripts — shared across all boards. Objects reference a script +# by script_name = "key". Scripts do NOT live on individual boards. +[scripts] +greeter = """ +fn init() { # optional, run once at startup + log(`player at ${Board.player_x}, ${Board.player_y}`); + set_tile(2); # write: change my glyph +} +fn tick(dt) { + if Queue.length() == 0 && !blocked(North) { move(North); } +} +fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); } +""" + +# Each board is a named subtable under [boards]. The board key ("room1") is +# used for cross-board references (e.g. portals) and becomes current_board_name. +[boards.room1.map] +name = "Room One" width = 60 height = 25 player_start = [30, 12] # OR a grid char: player_start = "@" # Optional: override the default font for this board. -[font] +[boards.room1.font] path = "assets/my_font.png" tile_w = 8 tile_h = 16 -[palette] +[boards.room1.palette] " " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" } "#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" } "o" = { archetype = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } # solid + pushable (any dir) -"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } # crate, pushable east/west only -"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } # crate, pushable north/south only +"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } # pushable east/west only +"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } # pushable north/south only -# Optional visual floor layer (drawn under every empty cell). Four forms: -# floor = "grass" # 1. one generator for the whole board -# [floor] # 2. one fixed glyph everywhere +# Optional visual floor layer. Four forms: +# boards.room1.floor = "grass" # 1. one generator for the whole board +# [boards.room1.floor] # 2. one fixed glyph everywhere # tile = "." fg = "#1c3a1c" bg = "#0a1a0a" -# [floor] # 3. a grid with its own palette -# content = """...""" # (must match width/height) -# [floor.palette] # entries are generator names OR glyphs -# "g" = "grass" # generator: "grass" | "dirt" | "stone" +# [boards.room1.floor] # 3. a grid with its own palette +# content = """...""" +# [boards.room1.floor.palette] +# "g" = "grass" # generator: "grass" | "dirt" | "stone" # "." = { tile = ".", fg = "#222", bg = "#000" } -# (Omitting [floor] entirely = black-on-black space, the default.) -[floor] +[boards.room1.floor] content = """ gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg gggggggggggggggddddddddddddddddddddddddddddddggggggggggggggg gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg """ -[floor.palette] +[boards.room1.floor.palette] "g" = "grass" "d" = "dirt" -[grid] +[boards.room1.grid] content = """ ############################################################ # G # ############################################################ """ -[[objects]] # optional +[[boards.room1.objects]] # optional; array of tables # Placement: either `x`/`y`, OR a `palette` char that appears in the grid. # Convention: object palette chars are uppercase letters (here `G`). The char # must appear exactly once in the grid, be unique across objects, and not be a @@ -285,38 +308,21 @@ tile = "#" fg = "#aa3333" bg = "#000000" solid = false # blocks movement? defaults true. (pushable defaults false) -name = "greeter" # optional unique name; readable via my_name() / object_id_for_name() -script_name = "greeter" # references a key in [scripts] +name = "greeter" # optional unique name; readable via Me.name / Board.named() +script_name = "greeter" # references a key in the world-level [scripts] table -[[portals]] # optional; parsed but not yet runtime-wired +[[boards.room1.portals]] # optional; parsed but not yet runtime-wired x = 59 y = 12 -target_map = "cave" +target_map = "room2" target_entry = "west_door" - -# Named Rhai scripts, referenced by objects via `script_name`. -[scripts] -greeter = """ -fn init() { # optional, run once at startup - log(`player at ${Board.player_x}, ${Board.player_y}`); # read the world - set_tile(2); # write: change my glyph -} -# optional, run every frame; dt = elapsed seconds. move() costs 250 ms, so the -# object steps ~4 cells/sec regardless of frame rate. blocked(dir) avoids walking -# into a solid (or another object's already-queued move); Queue.length()/Queue.clear() -# inspect/empty this object's own pending-action queue. -fn tick(dt) { - if Queue.length() == 0 && !blocked(North) { move(North); } -} -# optional, fires when something steps into this object; id = bumper's object id, -# or -1 for the player. -fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); } -""" ``` -The optional `[floor]` section declares the visual floor layer (drawn under every empty cell, and revealed when a crate/object is pushed away); see `floor.rs`. It takes one of four forms: omitted (black-on-black space, the default), a generator name (`floor = "grass"`), a single fixed glyph (`[floor]` with `tile`/`fg`/`bg`), or a grid (`[floor] content` + `[floor.palette]`, whose entries are generator names or fixed glyphs). The three built-in generators are `grass`/`dirt`/`stone`. Floor parsing is best-effort: an unknown generator/char or a grid-dimension mismatch is recorded on `Board::load_errors` and those cells fall back to the default empty glyph. +The optional `[boards.NAME.floor]` section declares the visual floor layer (drawn under every empty cell, revealed when a crate is pushed away); see `floor.rs`. Forms: omitted (black-on-black, the default), a generator name string, a single fixed glyph (table with `tile`/`fg`/`bg`), or a grid with its own palette. The three built-in generators are `grass`/`dirt`/`stone`. Floor parsing is best-effort: unknown generators/chars and dimension mismatches fall back to the default glyph. -Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coordinates **or** a single grid char to locate (convention `"@"`; that cell loads as `Empty`, like an object placeholder). The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning, as does any grid character that is neither a `[palette]` key nor an object/player `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): a placement char that appears multiple times uses the first occurrence, a missing object char drops that object, a missing player char falls back to `(0, 0)`, and one-solid-per-cell conflicts drop the later solid — each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player joins one-solid-per-cell** and *wins* its cell: it is placed first, silently clearing solid terrain under it and dropping any solid object that lands there. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, `log(s)`, `say(s)` (displays a speech bubble above the object for 3 s, replacing any existing bubble from that object), and `scroll(lines)` (opens a full-screen scrollable overlay; pauses ticks until dismissed; if the lines include choice entries the player must select one, which fires a named function on the source object via `send`). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object (a blocked move still costs the cooldown), so objects can't act infinitely fast. When two objects move into the same cell on one tick, **lowest id wins** (= load order; the earlier-loaded object lands, the later is pushed or blocked) and the object that was moved into receives `bump(id)`. +Colors are `"#RRGGBB"` hex strings. `player_start` accepts `[x, y]` coordinates or a grid char (convention `"@"`; that cell loads as `Empty`). `tile` accepts a single-character string (`tile = " "`) or an integer (`tile = 35`). The grid multi-line string's leading newline is trimmed by TOML. Unknown archetype names produce an `ErrorBlock` and a logged warning. Objects may be placed by `x`/`y` or a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a 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 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()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)`, `set_tile(n)`, `log(s)`, `say(s)`, and `scroll(lines)`. 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(id)`. + +**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)`). ### Key design decisions @@ -327,7 +333,7 @@ Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coord - **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games. Adding a variant: the `match`es in `behavior()`/`name()`/`default_glyph()` are exhaustive (the compiler flags them), but **`TryFrom<&str>` and `ALL_ARCHETYPES` are not** — forget the `TryFrom` arm and the archetype silently loads as `ErrorBlock`. Also update the map-format example. - **`Board` is the complete unit** — grid, player, objects, and portals all live on `Board`, matching how ZZT treats a "board". No separate wrapper struct. - **Glyph (visual) and Archetype (behavior) are decoupled** — each cell has its own `Glyph` (so colors can vary per-cell, e.g. fire flickering) while sharing an `Archetype` with other cells of the same type. -- **File loading happens in `main()`** before the window is created, so board dimensions are available for window sizing. +- **World loading happens in `main()`** before the terminal is initialized, so load errors print to a normal screen and board dimensions are known before the game loop starts. eframe runs on its own event loop thread; do not assume single-threaded execution. @@ -345,5 +351,5 @@ Today's baked-in assumptions that will need to change (don't make `Board.player` - **Scripting growth** — more event hooks beyond `init`/`tick`/`bump` (e.g. `on_touch` when the player steps onto an object), a larger action/read vocabulary (more actions could carry a `time_cost`), and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call). - **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap` with a `next_object_id` counter; `add_object` allocates), so references survive reordering. What's still missing: a `remove_object`, and wiring live spawn/destroy into the `ScriptHost` (it builds one `ObjectRuntime` per object once in `GameState::new`, so an object added after that has no script runtime until the host is rebuilt). -- **Portals & multi-board** — `PortalDef` is parsed but not runtime-wired; there is no multi-board world/loading yet (the engine loads a single map). +- **Portals & multi-board** — `PortalDef` is parsed but not runtime-wired. Multi-board world loading **is done**: `world::load` loads all boards as `Rc>`, `GameState` owns the whole `World` and tracks `current_board_name`. What's still missing: a `GameState::enter_board(name)` method that updates `current_board_name` and rebuilds the `ScriptHost` for the new board's objects. - **Load-error surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup). diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 725ca5d..164df97 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use crate::archetype::Archetype; use crate::archetype::Archetype::Empty; use crate::font::FontSpec; @@ -61,16 +61,11 @@ pub struct Board { pub font: Option, /// Integer scale factor applied to tiles when rendering this board. 1 = natural size. pub zoom: u32, - /// Named Rhai scripts available on this board: script name → source text. - /// - /// All script source lives here; [`ObjectDef`]s and [`Board::board_script_name`] - /// reference entries by name. This means multiple objects can share the same - /// source while each running instance gets its own Rhai scope at runtime. - pub scripts: HashMap, - /// Name of the board-level script in [`Board::scripts`], if any. + /// Name of the board-level script in the world script pool, if any. /// /// A board script runs on the board as a whole (e.g. `on_enter`, `on_tick`) - /// rather than being tied to a specific object cell. + /// rather than being tied to a specific object cell. Scripts live in + /// [`World::scripts`](crate::world::World) and are looked up by this name. pub board_script_name: Option, /// Nonfatal problems collected while loading this map (e.g. unknown /// archetypes, dropped objects, recovered placement chars), as red-on-black @@ -313,7 +308,7 @@ impl Board { #[cfg(test)] pub(crate) mod tests { - use std::collections::{BTreeMap, HashMap}; + use std::collections::BTreeMap; use color::Rgba8; use crate::archetype::Archetype; use crate::glyph::Glyph; @@ -322,14 +317,13 @@ pub(crate) mod tests { use crate::utils::{ObjectId, Player, Solid}; use super::Board; - /// Builds an all-empty `w×h` board with the given player position, objects, - /// and `(name, source)` scripts. Assigns sequential ids (1..=n) to objects. + /// Builds an all-empty `w×h` board with the given player position and objects. + /// Assigns sequential ids (1..=n) to objects. pub(crate) fn open_board( w: usize, h: usize, player: (i32, i32), objects: Vec, - scripts: &[(&str, &str)], ) -> Board { let mut object_map: BTreeMap = BTreeMap::new(); let mut next_object_id: ObjectId = 1; @@ -351,7 +345,6 @@ pub(crate) mod tests { portals: Vec::new(), font: None, zoom: 1, - scripts: scripts.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::>(), board_script_name: None, load_errors: Vec::new(), } @@ -374,7 +367,7 @@ pub(crate) mod tests { #[test] fn solid_at_reports_wall_object_and_empty() { - let mut board = open_board(4, 1, (3, 0), vec![], &[]); + let mut board = open_board(4, 1, (3, 0), vec![]); board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall); board.add_object(ObjectDef::new(2, 0)); @@ -398,14 +391,14 @@ pub(crate) mod tests { fn non_solid_object_does_not_block() { let mut obj = ObjectDef::new(1, 0); obj.solid = false; - let board = open_board(3, 1, (0, 0), vec![obj], &[]); + let board = open_board(3, 1, (0, 0), vec![obj]); assert!(board.solid_at(1, 0).is_none()); assert!(board.is_passable(1, 0)); } #[test] fn solid_at_reports_player() { - let board = open_board(3, 1, (1, 0), vec![], &[]); + let board = open_board(3, 1, (1, 0), vec![]); match board.solid_at(1, 0) { Some(Solid::Player) => {} _ => panic!("expected Solid::Player at the player's cell"), @@ -415,7 +408,7 @@ pub(crate) mod tests { #[test] fn in_bounds_checks_grid_boundaries() { - let board = open_board(3, 2, (0, 0), vec![], &[]); + let board = open_board(3, 2, (0, 0), vec![]); assert!(board.in_bounds((0, 0))); assert!(board.in_bounds((2, 1))); assert!(!board.in_bounds((-1, 0))); @@ -426,13 +419,13 @@ pub(crate) mod tests { #[test] fn can_push_is_read_only_and_correct() { - let mut board = open_board(3, 1, (0, 0), vec![], &[]); + let mut board = open_board(3, 1, (0, 0), vec![]); crate_at(&mut board, 1, 0); assert!(board.can_push(1, 0, Direction::East)); assert_eq!(board.get(1, 0).1, Archetype::Crate); // no mutation assert_eq!(board.get(2, 0).1, Archetype::Empty); - let mut board = open_board(3, 1, (0, 0), vec![], &[]); + let mut board = open_board(3, 1, (0, 0), vec![]); crate_at(&mut board, 1, 0); wall_at(&mut board, 2, 0); assert!(!board.can_push(1, 0, Direction::East)); @@ -441,7 +434,7 @@ pub(crate) mod tests { #[test] fn push_into_player_pushes_player() { // Crate shoved east into the player slides the player along into open space. - let mut board = open_board(4, 1, (2, 0), vec![], &[]); + let mut board = open_board(4, 1, (2, 0), vec![]); crate_at(&mut board, 1, 0); assert!(board.can_push(1, 0, Direction::East)); board.push(1, 0, Direction::East); @@ -453,7 +446,7 @@ pub(crate) mod tests { #[test] fn push_into_player_blocked_by_wall() { // Player backed against a wall: push has nowhere to go, nothing moves. - let mut board = open_board(4, 1, (2, 0), vec![], &[]); + let mut board = open_board(4, 1, (2, 0), vec![]); crate_at(&mut board, 1, 0); wall_at(&mut board, 3, 0); assert!(!board.can_push(1, 0, Direction::East)); @@ -465,7 +458,7 @@ pub(crate) mod tests { #[test] fn glyph_at_uses_floor_for_empty_and_grid_for_solid() { // Player parked at (2,0) so it doesn't overlap either asserted cell. - let mut board = open_board(3, 1, (2, 0), vec![], &[]); + let mut board = open_board(3, 1, (2, 0), vec![]); let floor_glyph = Glyph { tile: '.' as u32, fg: Rgba8 { r: 10, g: 20, b: 30, a: 255 }, @@ -479,7 +472,7 @@ pub(crate) mod tests { #[test] fn fresh_board_is_valid_and_reports_errors() { - let mut board = open_board(1, 1, (0, 0), vec![], &[]); + let mut board = open_board(1, 1, (0, 0), vec![]); assert!(board.is_valid()); board.report_error("something went wrong"); assert!(!board.is_valid()); diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 9461b1a..f07c70d 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -1,10 +1,10 @@ use crate::action::Action; +use crate::board::Board; use crate::log::LogLine; use crate::script::ScriptHost; -use std::cell::{Ref, RefCell, RefMut}; -use std::rc::Rc; +use crate::world::World; +use std::cell::{Ref, RefMut}; use std::time::Duration; -use crate::board::Board; use crate::utils::{Direction, ObjectId, ScriptArg, Solid}; /// How long a `say()` speech bubble stays on screen, in seconds. @@ -38,18 +38,19 @@ pub struct SpeechBubble { /// Holds the active game world and provides game-logic operations. /// /// `GameState` is the boundary between the engine (rendering, input) and the -/// game data. It owns the board (behind a shared `Rc>`), -/// the message log, and the [`ScriptHost`] driving object scripts. Front-ends and -/// internal logic reach the board through [`board`](GameState::board) / -/// [`board_mut`](GameState::board_mut). Scripts read the board directly and -/// request mutations as commands, applied by [`apply_commands`](GameState::apply_commands) -/// after each script batch. +/// game data. It owns a [`World`] (all boards + world scripts) and tracks which +/// board is currently active. Front-ends reach the active board through +/// [`board`](GameState::board) / [`board_mut`](GameState::board_mut). Scripts +/// read the board and queue mutations via commands applied by `resolve` after +/// each script batch. pub struct GameState { - /// The scriptable world, shared with the script host's read getters. - board: Rc>, + /// All boards and world-level scripts. + world: World, + /// Key of the currently active board in [`world.boards`](World::boards). + current_board_name: String, /// The in-game message log, oldest first (newest pushed at the end). pub log: Vec, - /// The Rhai scripting runtime driving this board's scripted objects. + /// The Rhai scripting runtime for the active board's objects. scripts: ScriptHost, /// Active speech bubbles from `say()` calls, displayed by the front-end over the board. pub speech_bubbles: Vec, @@ -59,34 +60,67 @@ pub struct GameState { } impl GameState { - /// Creates a `GameState` from a pre-loaded [`Board`]. + /// Creates a [`GameState`] from a loaded [`World`], starting on `world.start`. /// - /// Compiles the board's object scripts but does **not** run any of them; - /// call [`GameState::run_init`] once the game is ready to start. Any script - /// compile errors are surfaced into the log here. - pub fn new(board: Board) -> Self { - let board = Rc::new(RefCell::new(board)); - let scripts = ScriptHost::new(&board); + /// The starting board's objects are compiled and registered with the + /// [`ScriptHost`] using `world.scripts`. No lifecycle hooks are run here; + /// call [`run_init`](GameState::run_init) once the game is ready to start. + /// Compile errors are surfaced into the log immediately. + pub fn from_world(world: World) -> Self { + let name = world.start.clone(); + let host = ScriptHost::new( + world.boards.get(&name).expect("world::load guarantees start board exists"), + &world.scripts, + ); let mut state = Self { - board, + world, + current_board_name: name, log: Vec::new(), - scripts, + scripts: host, speech_bubbles: Vec::new(), active_scroll: None, }; - // Surface any compile-time script errors collected during ScriptHost::new. state.drain_errors(); state } + /// Creates a `GameState` from a single [`Board`] with no scripts. + /// + /// Test-only convenience. Use [`from_world`](GameState::from_world) in production. + #[cfg(test)] + pub fn new(board: Board) -> Self { + Self::with_scripts(board, std::collections::HashMap::new()) + } + + /// Creates a `GameState` from a single [`Board`] and an explicit script pool. + /// + /// Test-only convenience. Use [`from_world`](GameState::from_world) in production. + #[cfg(test)] + pub fn with_scripts(board: Board, scripts: std::collections::HashMap) -> Self { + // Wrap the bare board in Rc> to match World::boards storage. + let board_ref = std::rc::Rc::new(std::cell::RefCell::new(board)); + let world = World { + name: String::new(), + start: "board".to_string(), + scripts, + boards: std::collections::HashMap::from([("board".to_string(), board_ref)]), + }; + Self::from_world(world) + } + /// Borrows the active board for reading (e.g. by a front-end renderer). pub fn board(&self) -> Ref<'_, Board> { - self.board.borrow() + self.world.boards[&self.current_board_name].borrow() } /// Borrows the active board for mutation. pub fn board_mut(&self) -> RefMut<'_, Board> { - self.board.borrow_mut() + self.world.boards[&self.current_board_name].borrow_mut() + } + + /// The name (key) of the currently active board within the world. + pub fn current_board_name(&self) -> &str { + &self.current_board_name } /// Appends a styled message to the log. diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 39d9f8e..22abd55 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -41,10 +41,6 @@ pub struct MapFile { /// Any `[[portals]]` entries. Optional; defaults to empty. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub portals: Vec, - /// The `[scripts]` table: maps script names to Rhai source text. - /// Optional; defaults to empty. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub scripts: HashMap, } /// The `[map]` header section of a map file. @@ -570,7 +566,6 @@ impl TryFrom for Board { portals: mf.portals, font, zoom: mf.map.zoom, - scripts: mf.scripts, board_script_name: mf.map.board_script_name, load_errors, }) @@ -705,7 +700,6 @@ impl From<&Board> for MapFile { floor: board.floor_spec.clone(), objects, portals, - scripts: board.scripts.clone(), } } } diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 88a40aa..665695e 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -164,7 +164,10 @@ impl ScriptHost { /// compiles every script referenced by an object (once each), and creates a fresh /// scope and output queue per scripted object. Compile errors and references to /// unknown scripts are queued onto the error sink; no script is run here. - pub fn new(board_cell: &BoardRef) -> Self { + /// + /// `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_cell: &BoardRef, script_sources: &HashMap) -> Self { let board_queue: BoardQueue = Rc::new(RefCell::new(Vec::new())); let queues: QueueMap = Rc::new(RefCell::new(HashMap::new())); let errors: ErrorSink = Rc::new(RefCell::new(Vec::new())); @@ -186,7 +189,7 @@ impl ScriptHost { for obj in board.objects.values() { let Some(name) = obj.script_name.as_ref() else { continue; }; if scripts.contains_key(name) || failed.contains(name) { continue; } - match board.scripts.get(name) { + match script_sources.get(name) { Some(src) => match engine.compile(src) { Ok(ast) => { let defines = |n: &str, params: usize| { diff --git a/kiln-core/src/tests/actions.rs b/kiln-core/src/tests/actions.rs index 33c8fd5..7127db3 100644 --- a/kiln-core/src/tests/actions.rs +++ b/kiln-core/src/tests/actions.rs @@ -2,18 +2,12 @@ use std::time::Duration; use crate::archetype::Archetype; use crate::board::tests::{crate_at, open_board, wall_at}; use crate::game::GameState; -use super::{scripted_object, log_texts}; +use super::{scripted_object, log_texts, scripts_from}; #[test] fn move_command_relocates_the_source_object() { - let board = open_board( - 5, - 3, - (0, 0), - vec![scripted_object(2, 1, "m")], - &[("m", "fn init() { move(East); }")], - ); - let mut game = GameState::new(board); + let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "m")]); + let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); game.run_init(); let b = game.board(); // East increments x by one; the object started at (2, 1). @@ -23,28 +17,16 @@ fn move_command_relocates_the_source_object() { #[test] fn move_into_a_wall_or_edge_is_a_noop() { // Object at the west edge moving west: out of bounds, ignored. - let board = open_board( - 5, - 3, - (0, 0), - vec![scripted_object(0, 1, "m")], - &[("m", "fn init() { move(West); }")], - ); - let mut game = GameState::new(board); + let board = open_board(5, 3, (0, 0), vec![scripted_object(0, 1, "m")]); + let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(West); }")])); game.run_init(); assert_eq!(game.board().objects[&1].x, 0); } #[test] fn set_tile_command_changes_the_source_glyph() { - let board = open_board( - 5, - 3, - (0, 0), - vec![scripted_object(2, 1, "s")], - &[("s", "fn init() { set_tile(7); }")], - ); - let mut game = GameState::new(board); + let board = open_board(5, 3, (0, 0), vec![scripted_object(2, 1, "s")]); + let mut game = GameState::with_scripts(board, scripts_from(&[("s", "fn init() { set_tile(7); }")])); game.run_init(); assert_eq!(game.board().objects[&1].glyph.tile, 7); } @@ -52,15 +34,9 @@ fn set_tile_command_changes_the_source_glyph() { #[test] fn object_pushes_crate_on_init() { // A scripted object moving east into a crate shoves it (step_object path). - let mut board = open_board( - 4, - 1, - (0, 0), - vec![scripted_object(1, 0, "m")], - &[("m", "fn init() { move(East); }")], - ); + let mut board = open_board(4, 1, (0, 0), vec![scripted_object(1, 0, "m")]); crate_at(&mut board, 2, 0); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); game.run_init(); let b = game.board(); assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 0)); @@ -71,14 +47,8 @@ fn object_pushes_crate_on_init() { #[test] fn object_push_into_player() { // A scripted object moving into the player pushes the player when there's room. - let board = open_board( - 5, - 1, - (2, 0), - vec![scripted_object(1, 0, "m")], - &[("m", "fn init() { move(East); }")], - ); - let mut game = GameState::new(board); + let board = open_board(5, 1, (2, 0), vec![scripted_object(1, 0, "m")]); + let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); game.run_init(); { let b = game.board(); @@ -87,15 +57,9 @@ fn object_push_into_player() { } // With a wall behind the player, the object is blocked and nothing moves. - let mut board = open_board( - 4, - 1, - (2, 0), - vec![scripted_object(1, 0, "m")], - &[("m", "fn init() { move(East); }")], - ); + let mut board = open_board(4, 1, (2, 0), vec![scripted_object(1, 0, "m")]); wall_at(&mut board, 3, 0); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); }")])); game.run_init(); let b = game.board(); assert_eq!((b.objects[&1].x, b.objects[&1].y), (1, 0)); @@ -106,14 +70,8 @@ fn object_push_into_player() { fn move_cost_rate_limits_repeated_moves() { // init queues two moves; only the first resolves immediately, the second must // wait the full 250 ms cooldown. - let board = open_board( - 5, - 1, - (0, 0), - vec![scripted_object(1, 0, "m")], - &[("m", "fn init() { move(East); move(East); }")], - ); - let mut game = GameState::new(board); + let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "m")]); + let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(East); }")])); game.run_init(); assert_eq!(game.board().objects[&1].x, 2); // first move applied (1 -> 2) @@ -131,15 +89,9 @@ fn move_cost_rate_limits_repeated_moves() { fn inline_delay_paces_subsequent_moves() { // move() always appends a Delay(250 ms) to the queue, so a second queued move // is held back by that delay even if the first move was blocked by a wall. - let mut board = open_board( - 3, - 3, - (0, 0), - vec![scripted_object(1, 1, "m")], - &[("m", "fn init() { move(East); move(South); }")], - ); + let mut board = open_board(3, 3, (0, 0), vec![scripted_object(1, 1, "m")]); wall_at(&mut board, 2, 1); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts_from(&[("m", "fn init() { move(East); move(South); }")])); game.run_init(); // First (eastward) move is blocked by the wall: object hasn't moved. assert_eq!((game.board().objects[&1].x, game.board().objects[&1].y), (1, 1)); @@ -158,17 +110,11 @@ fn inline_delay_paces_subsequent_moves() { fn queue_length_reports_pending_actions() { // Two zero-cost actions are queued before the length is read, then all three // (incl. the log) drain in one pump. - let board = open_board( - 3, - 1, - (0, 0), - vec![scripted_object(1, 0, "q")], - &[( - "q", - "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }", - )], - ); - let mut game = GameState::new(board); + let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "q")]); + let mut game = GameState::with_scripts(board, scripts_from(&[( + "q", + "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }", + )])); game.run_init(); assert!(log_texts(&game).iter().any(|t| t == "len=2")); assert_eq!(game.board().objects[&1].glyph.tile, 6); // last set_tile won @@ -177,14 +123,8 @@ fn queue_length_reports_pending_actions() { #[test] fn queue_clear_drops_pending_actions() { // clear() empties the output queue mid-script, so the queued moves never run. - let board = open_board( - 5, - 1, - (0, 0), - vec![scripted_object(1, 0, "c")], - &[("c", "fn init() { move(East); move(East); Queue.clear(); }")], - ); - let mut game = GameState::new(board); + let board = open_board(5, 1, (0, 0), vec![scripted_object(1, 0, "c")]); + let mut game = GameState::with_scripts(board, scripts_from(&[("c", "fn init() { move(East); move(East); Queue.clear(); }")])); game.run_init(); game.tick(Duration::from_millis(300)); assert_eq!(game.board().objects[&1].x, 1); // never moved @@ -193,33 +133,21 @@ fn queue_clear_drops_pending_actions() { #[test] fn blocked_reports_solid_and_clear() { // Solid ahead (a wall): blocked() is true. - let mut board = open_board( - 3, - 1, - (0, 0), - vec![scripted_object(1, 0, "b")], - &[( - "b", - "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", - )], - ); + let mut board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); wall_at(&mut board, 2, 0); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts_from(&[( + "b", + "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", + )])); game.run_init(); assert_eq!(game.board().objects[&1].glyph.tile, 9); // Open ahead, nothing pending: blocked() is false. - let board = open_board( - 3, - 1, - (0, 0), - vec![scripted_object(1, 0, "b")], - &[( - "b", - "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", - )], - ); - let mut game = GameState::new(board); + let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); + let mut game = GameState::with_scripts(board, scripts_from(&[( + "b", + "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }", + )])); game.run_init(); assert_eq!(game.board().objects[&1].glyph.tile, 7); } @@ -236,15 +164,11 @@ fn blocked_sees_earlier_objects_pending_move() { scripted_object(1, 1, "mover"), scripted_object(3, 1, "checker"), ], - &[ - ("mover", "fn tick(dt) { move(East); }"), - ( - "checker", - "fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }", - ), - ], ); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts_from(&[ + ("mover", "fn tick(dt) { move(East); }"), + ("checker", "fn tick(dt) { if blocked(West) { set_tile(1); } else { set_tile(2); } }"), + ])); game.tick(Duration::from_millis(16)); let b = game.board(); assert_eq!((b.objects[&1].x, b.objects[&1].y), (2, 1)); diff --git a/kiln-core/src/tests/collision.rs b/kiln-core/src/tests/collision.rs index 9e4d615..d3e15d0 100644 --- a/kiln-core/src/tests/collision.rs +++ b/kiln-core/src/tests/collision.rs @@ -1,7 +1,7 @@ use std::time::Duration; use crate::board::tests::open_board; use crate::game::GameState; -use super::{scripted_object, log_texts}; +use super::{scripted_object, log_texts, scripts_from}; #[test] fn collision_priority_resolves_in_array_order_and_bumps() { @@ -12,18 +12,11 @@ fn collision_priority_resolves_in_array_order_and_bumps() { 2, (0, 1), // player off the contested row vec![scripted_object(0, 0, "e"), scripted_object(2, 0, "w")], - &[ - ( - "e", - "fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }", - ), - ( - "w", - "fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }", - ), - ], ); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts_from(&[ + ("e", "fn init() { move(East); } fn bump(id) { log(`o0 by ${id}`); }"), + ("w", "fn init() { move(West); } fn bump(id) { log(`o1 by ${id}`); }"), + ])); game.run_init(); // The bump fires during resolution but its log is emitted into obj0's queue; // a later tick (past both objects' move cooldown) flushes it to the game log. diff --git a/kiln-core/src/tests/mod.rs b/kiln-core/src/tests/mod.rs index 7ff3058..fe81fd6 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -4,20 +4,22 @@ mod map_file; mod movement; mod scripting; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use crate::archetype::Archetype; use crate::board::Board; use crate::object_def::ObjectDef; use crate::utils::Player; use crate::game::GameState; -/// Builds a 1×1 board with a single object that optionally references a -/// script, plus the given `(name, source)` script table entries. -fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> Board { +/// Builds a 1×1 board with a single object that optionally references a script, +/// and returns both the board and the `(name, source)` entries as a script map. +/// +/// Pass the returned scripts to [`GameState::with_scripts`] so they are compiled. +fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> (Board, HashMap) { let mut object = ObjectDef::new(0, 0); object.id = 1; object.script_name = object_script.map(str::to_string); - Board { + let board = Board { name: "test".into(), width: 1, height: 1, @@ -30,10 +32,16 @@ fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> B portals: Vec::new(), font: None, zoom: 1, - scripts: scripts.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), board_script_name: None, load_errors: Vec::new(), - } + }; + (board, scripts_from(scripts)) +} + +/// Converts a `(name, source)` slice into a `HashMap` suitable for +/// [`GameState::with_scripts`]. +fn scripts_from(pairs: &[(&str, &str)]) -> HashMap { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() } /// Returns an `ObjectDef` at `(x, y)` bound to the named script. diff --git a/kiln-core/src/tests/movement.rs b/kiln-core/src/tests/movement.rs index 9489fe1..2740b0e 100644 --- a/kiln-core/src/tests/movement.rs +++ b/kiln-core/src/tests/movement.rs @@ -11,7 +11,7 @@ fn pushing_a_crate_reveals_the_floor_underneath() { // The cell a crate is pushed off of becomes Empty and glyph_at shows floor. // After the push the player is at (1,0); check the cell the player vacated // (0,0) — it is Empty and must reveal the floor glyph, not black-on-black. - let mut board = open_board(4, 1, (0, 0), vec![], &[]); + let mut board = open_board(4, 1, (0, 0), vec![]); let floor_glyph = Glyph { tile: ',' as u32, fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 }, @@ -29,7 +29,7 @@ fn pushing_a_crate_reveals_the_floor_underneath() { #[test] fn player_pushes_single_crate() { - let mut board = open_board(4, 1, (0, 0), vec![], &[]); + let mut board = open_board(4, 1, (0, 0), vec![]); crate_at(&mut board, 1, 0); let mut game = GameState::new(board); game.try_move(Direction::East); @@ -41,7 +41,7 @@ fn player_pushes_single_crate() { #[test] fn push_blocked_by_wall_moves_nothing() { - let mut board = open_board(4, 1, (0, 0), vec![], &[]); + let mut board = open_board(4, 1, (0, 0), vec![]); crate_at(&mut board, 1, 0); wall_at(&mut board, 2, 0); let mut game = GameState::new(board); @@ -55,7 +55,7 @@ fn push_blocked_by_wall_moves_nothing() { #[test] fn push_blocked_by_edge_moves_nothing() { // Crate on the last column; player pushes it toward the board edge. - let mut board = open_board(2, 1, (0, 0), vec![], &[]); + let mut board = open_board(2, 1, (0, 0), vec![]); crate_at(&mut board, 1, 0); let mut game = GameState::new(board); game.try_move(Direction::East); @@ -66,7 +66,7 @@ fn push_blocked_by_edge_moves_nothing() { #[test] fn cascade_pushes_two_crates() { - let mut board = open_board(5, 1, (0, 0), vec![], &[]); + let mut board = open_board(5, 1, (0, 0), vec![]); crate_at(&mut board, 1, 0); crate_at(&mut board, 2, 0); let mut game = GameState::new(board); @@ -80,7 +80,7 @@ fn cascade_pushes_two_crates() { #[test] fn cascade_blocked_by_wall_moves_nothing() { - let mut board = open_board(5, 1, (0, 0), vec![], &[]); + let mut board = open_board(5, 1, (0, 0), vec![]); crate_at(&mut board, 1, 0); crate_at(&mut board, 2, 0); wall_at(&mut board, 3, 0); @@ -97,7 +97,7 @@ fn player_pushes_pushable_solid_object() { // A solid, pushable object is shoved exactly like a crate. let mut obj = ObjectDef::new(1, 0); obj.pushable = true; - let board = open_board(4, 1, (0, 0), vec![obj], &[]); + let board = open_board(4, 1, (0, 0), vec![obj]); let mut game = GameState::new(board); game.try_move(Direction::East); let b = game.board(); @@ -108,7 +108,7 @@ fn player_pushes_pushable_solid_object() { #[test] fn hcrate_pushes_east_but_not_north() { // Pushing east: the HCrate slides. - let mut board = open_board(4, 1, (0, 0), vec![], &[]); + let mut board = open_board(4, 1, (0, 0), vec![]); stamp(&mut board, 1, 0, Archetype::HCrate); let mut game = GameState::new(board); game.try_move(Direction::East); @@ -119,7 +119,7 @@ fn hcrate_pushes_east_but_not_north() { drop(b); // Pushing north into an HCrate: blocked, nothing moves. - let mut board = open_board(1, 4, (0, 3), vec![], &[]); + let mut board = open_board(1, 4, (0, 3), vec![]); stamp(&mut board, 0, 2, Archetype::HCrate); let mut game = GameState::new(board); game.try_move(Direction::North); @@ -131,7 +131,7 @@ fn hcrate_pushes_east_but_not_north() { #[test] fn vcrate_pushes_north_but_not_east() { // Pushing north: the VCrate slides. - let mut board = open_board(1, 4, (0, 3), vec![], &[]); + let mut board = open_board(1, 4, (0, 3), vec![]); stamp(&mut board, 0, 2, Archetype::VCrate); let mut game = GameState::new(board); game.try_move(Direction::North); @@ -142,7 +142,7 @@ fn vcrate_pushes_north_but_not_east() { drop(b); // Pushing east into a VCrate: blocked, nothing moves. - let mut board = open_board(4, 1, (0, 0), vec![], &[]); + let mut board = open_board(4, 1, (0, 0), vec![]); stamp(&mut board, 1, 0, Archetype::VCrate); let mut game = GameState::new(board); game.try_move(Direction::East); diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index afdc6ac..d43ea59 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -2,15 +2,15 @@ use std::time::Duration; use crate::board::tests::open_board; use crate::game::{GameState, ScrollLine}; use crate::utils::Direction; -use super::{board_with_object, scripted_object, log_texts}; +use super::{board_with_object, scripted_object, log_texts, scripts_from}; #[test] fn init_runs_only_on_run_init_not_at_construction() { - let board = board_with_object( + let (board, scripts) = board_with_object( Some("greet"), &[("greet", r#"fn init() { log("hello"); }"#)], ); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts); // init must not fire during construction / deserialization. assert!(game.log.is_empty()); @@ -20,11 +20,11 @@ fn init_runs_only_on_run_init_not_at_construction() { #[test] fn tick_calls_script_tick_with_elapsed_seconds() { - let board = board_with_object( + let (board, scripts) = board_with_object( Some("t"), &[("t", r#"fn tick(dt) { log(dt.to_string()); }"#)], ); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts); game.run_init(); assert!(game.log.is_empty()); @@ -37,16 +37,18 @@ fn tick_calls_script_tick_with_elapsed_seconds() { #[test] fn missing_hooks_and_no_script_are_noops() { // Object with no script: nothing happens. - let mut game = GameState::new(board_with_object(None, &[])); + let (board, scripts) = board_with_object(None, &[]); + let mut game = GameState::with_scripts(board, scripts); game.run_init(); game.tick(Duration::from_millis(33)); assert!(game.log.is_empty()); // Script defines neither init nor tick: also a no-op. - let mut game = GameState::new(board_with_object( + let (board, scripts) = board_with_object( Some("e"), &[("e", "fn other() { log(\"x\"); }")], - )); + ); + let mut game = GameState::with_scripts(board, scripts); game.run_init(); game.tick(Duration::from_millis(33)); assert!(game.log.is_empty()); @@ -55,11 +57,13 @@ fn missing_hooks_and_no_script_are_noops() { #[test] fn compile_and_unknown_script_errors_are_logged() { // A reference to a script name that isn't in the table. - let game = GameState::new(board_with_object(Some("ghost"), &[])); + let (board, scripts) = board_with_object(Some("ghost"), &[]); + let game = GameState::with_scripts(board, scripts); assert!(log_texts(&game)[0].contains("unknown script 'ghost'")); // A script that fails to compile is reported at construction time. - let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")])); + let (board, scripts) = board_with_object(Some("bad"), &[("bad", "fn init( {")]); + let game = GameState::with_scripts(board, scripts); assert!(log_texts(&game)[0].contains("failed to compile")); } @@ -70,9 +74,8 @@ fn script_reads_board_through_view() { 3, (3, 1), vec![scripted_object(2, 1, "r")], - &[("r", "fn init() { log(Board.player_x.to_string()); }")], ); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts_from(&[("r", "fn init() { log(Board.player_x.to_string()); }")])); game.run_init(); assert_eq!(log_texts(&game), vec!["3"]); } @@ -86,12 +89,11 @@ fn commands_are_routed_to_their_own_source_object() { 3, (0, 0), vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")], - &[ - ("e", "fn init() { move(East); }"), - ("w", "fn init() { move(West); }"), - ], ); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts_from(&[ + ("e", "fn init() { move(East); }"), + ("w", "fn init() { move(West); }"), + ])); game.run_init(); let b = game.board(); assert_eq!(b.objects[&1].x, 1); // moved east from 0 @@ -104,9 +106,8 @@ fn start_map_greeter_runs_init() { // confirm the greeter read the board (interpolated message) and wrote to // itself (set_tile). Also guards the example from drifting out of sync. let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml"); - let mut world = crate::world::load(path).expect("load start.toml"); - let board = world.boards.remove(&world.start).expect("start board"); - let mut game = GameState::new(board); + let world = crate::world::load(path).expect("load start.toml"); + let mut game = GameState::from_world(world); game.run_init(); assert!( log_texts(&game).iter().any(|t| t.contains("hello from object")), @@ -122,25 +123,22 @@ fn start_map_greeter_runs_init() { fn set_tag_adds_and_removes_via_my_id() { // A script calls set_tag(Me.id, "active", true) in init; the tag must be // present on the object afterward. - let board = board_with_object( + let (board, scripts) = board_with_object( Some("t"), &[("t", r#"fn init() { set_tag(Me.id, "active", true); }"#)], ); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts); game.run_init(); assert!(game.board().objects[&1].tags.contains("active")); // A script removes a pre-existing tag. - let board2 = board_with_object( + let (mut board2, scripts2) = board_with_object( Some("t2"), &[("t2", r#"fn init() { set_tag(Me.id, "active", false); }"#)], ); // Seed the tag before construction. - let mut game2 = GameState::new({ - let mut b = board2; // need mut to insert tag - b.objects.get_mut(&1).unwrap().tags.insert("active".to_string()); - b - }); + board2.objects.get_mut(&1).unwrap().tags.insert("active".to_string()); + let mut game2 = GameState::with_scripts(board2, scripts2); game2.run_init(); assert!(!game2.board().objects[&1].tags.contains("active")); } @@ -149,7 +147,7 @@ fn set_tag_adds_and_removes_via_my_id() { fn has_tag_reads_own_tags() { // set_tag queues an action; has_tag reads the board state. So set_tag in // init() takes effect after init returns; a subsequent tick sees it. - let board = board_with_object( + let (board, scripts) = board_with_object( Some("t"), &[( "t", @@ -157,7 +155,7 @@ fn has_tag_reads_own_tags() { fn tick(dt) { log(Me.has_tag("active").to_string()); }"#, )], ); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts); game.run_init(); game.tick(Duration::from_millis(16)); assert!(log_texts(&game).iter().any(|t| t == "true")); @@ -171,21 +169,15 @@ fn objects_with_tag_returns_matching_ids() { let mut obj2 = scripted_object(1, 0, "none"); // obj2 (id=2) has the "enemy" tag; obj1 (id=1) does not. obj2.tags.insert("enemy".to_string()); - let board = open_board( - 5, - 1, - (4, 0), - vec![obj1, obj2], - &[ - ("q", r#"fn init() { - let infos = Board.tagged("enemy"); - log(infos.len().to_string()); - log(infos[0].id.to_string()); - }"#), - ("none", ""), - ], - ); - let mut game = GameState::new(board); + let board = open_board(5, 1, (4, 0), vec![obj1, obj2]); + let mut game = GameState::with_scripts(board, scripts_from(&[ + ("q", r#"fn init() { + let infos = Board.tagged("enemy"); + log(infos.len().to_string()); + log(infos[0].id.to_string()); + }"#), + ("none", ""), + ])); game.run_init(); let texts = log_texts(&game); assert_eq!(texts[0], "1"); // exactly one match @@ -195,21 +187,21 @@ fn objects_with_tag_returns_matching_ids() { #[test] fn my_name_returns_name_or_empty_string() { // An object with a name set on its ObjectDef should see it via my_name(). - let mut board = board_with_object( + let (mut board, scripts) = board_with_object( Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)], ); board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string()); - let mut game = GameState::new(board); + let mut game = GameState::with_scripts(board, scripts); game.run_init(); assert_eq!(log_texts(&game), vec!["beacon"]); // An unnamed object should get an empty string. - let board2 = board_with_object( + let (board2, scripts2) = board_with_object( Some("n"), &[("n", r#"fn init() { log(Me.name); }"#)], ); - let mut game2 = GameState::new(board2); + let mut game2 = GameState::with_scripts(board2, scripts2); game2.run_init(); assert_eq!(log_texts(&game2), vec![""]); } @@ -221,21 +213,15 @@ fn object_id_for_name_finds_by_name() { let obj1 = scripted_object(0, 0, "q"); let mut obj2 = scripted_object(1, 0, "none"); obj2.name = Some("target".to_string()); - let board = open_board( - 5, - 1, - (4, 0), - vec![obj1, obj2], - &[ - ("q", r#"fn init() { - log(Board.named("target").id.to_string()); - let miss = Board.named("missing"); - log(if miss == () { "not_found" } else { miss.id.to_string() }); - }"#), - ("none", ""), - ], - ); - let mut game = GameState::new(board); + let board = open_board(5, 1, (4, 0), vec![obj1, obj2]); + let mut game = GameState::with_scripts(board, scripts_from(&[ + ("q", r#"fn init() { + log(Board.named("target").id.to_string()); + let miss = Board.named("missing"); + log(if miss == () { "not_found" } else { miss.id.to_string() }); + }"#), + ("none", ""), + ])); game.run_init(); let texts = log_texts(&game); assert_eq!(texts[0], "2"); // obj2 is id 2 @@ -247,14 +233,8 @@ fn object_id_for_name_finds_by_name() { fn player_bump_fires_with_negative_one() { // The player walks into a solid scripted object; the object is bumped with -1 // and the player does not move onto it. - let board = open_board( - 3, - 1, - (0, 0), - vec![scripted_object(1, 0, "b")], - &[("b", "fn bump(id) { log(`bumped by ${id}`); }")], - ); - let mut game = GameState::new(board); + let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "b")]); + let mut game = GameState::with_scripts(board, scripts_from(&[("b", "fn bump(id) { log(`bumped by ${id}`); }")])); game.run_init(); game.try_move(Direction::East); assert_eq!((game.board().player.x, game.board().player.y), (0, 0)); // blocked @@ -268,12 +248,8 @@ fn scroll_opens_on_player_bump() { // A solid object's bump() calls scroll([text, [choice, display]]). After the // player bumps it and a tick resolves the queued action, active_scroll is set // with the correct ScrollLine variants. - let board = open_board( - 3, 1, (0, 0), - vec![scripted_object(1, 0, "s")], - &[("s", r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#)], - ); - let mut game = GameState::new(board); + let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); + let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#)])); game.run_init(); game.try_move(Direction::East); game.tick(Duration::from_millis(16)); @@ -287,12 +263,8 @@ fn scroll_opens_on_player_bump() { #[test] fn close_scroll_without_choice_clears_it() { - let board = open_board( - 3, 1, (0, 0), - vec![scripted_object(1, 0, "s")], - &[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)], - ); - let mut game = GameState::new(board); + let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); + let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)])); game.run_init(); game.try_move(Direction::East); game.tick(Duration::from_millis(16)); @@ -306,15 +278,11 @@ fn close_scroll_without_choice_clears_it() { fn close_scroll_with_choice_dispatches_send_to_source() { // Closing with a choice string fires send() on the object that opened the // scroll; fn eat() logging "eaten" confirms the dispatch arrived. - let board = open_board( - 3, 1, (0, 0), - vec![scripted_object(1, 0, "s")], - &[("s", r#" - fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); } - fn eat() { log("eaten"); } - "#)], - ); - let mut game = GameState::new(board); + let board = open_board(3, 1, (0, 0), vec![scripted_object(1, 0, "s")]); + let mut game = GameState::with_scripts(board, scripts_from(&[("s", r#" + fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); } + fn eat() { log("eaten"); } + "#)])); game.run_init(); game.try_move(Direction::East); game.tick(Duration::from_millis(16)); diff --git a/kiln-core/src/world.rs b/kiln-core/src/world.rs new file mode 100644 index 0000000..ef35f16 --- /dev/null +++ b/kiln-core/src/world.rs @@ -0,0 +1,94 @@ +//! World type: a named collection of boards loaded from a single `.toml` file. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use crate::board::Board; +use crate::map_file::MapFile; +use serde::Deserialize; + +/// A named world containing one or more boards, loaded from a `.toml` file. +/// +/// The file groups all boards under a `[boards]` table. Each board key maps +/// to a full board definition (the same structure as a single-board map file, +/// but nested under `[boards..*]`). [`World::start`] names the board the +/// player enters first. +/// +/// Scripts live at the world level ([`World::scripts`]) rather than on each +/// board, so multiple boards can reference the same script name without +/// duplicating source text. +pub struct World { + /// Human-readable name for this world, from the `[world]` header. + pub name: String, + /// Key of the starting board; matches a key in [`World::boards`]. + pub start: String, + /// Named Rhai scripts shared across all boards: script name → source text. + /// + /// Objects on any board reference a script by name via + /// [`ObjectDef::script_name`](crate::object_def::ObjectDef). The script pool is + /// passed to [`GameState::from_world`](crate::game::GameState::from_world) at startup. + pub scripts: HashMap, + /// All boards in this world, keyed by their identifier string. + /// + /// Every board is always present here as a shared `Rc>` — no board + /// is ever "removed" when active. [`GameState`](crate::game::GameState) holds a + /// clone of the active board's `Rc` and borrows through it. + pub boards: HashMap>>, +} + +/// Serde target for the top-level `.toml` world file. +#[derive(Deserialize)] +struct WorldFile { + world: WorldHeader, + /// The `[scripts]` table: script name → Rhai source. Shared across all boards. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + scripts: HashMap, + /// Each value deserializes as a [`MapFile`], reusing the existing per-board + /// serde types from `map_file.rs`. + boards: HashMap, +} + +/// The `[world]` header section of a world file. +#[derive(Deserialize)] +struct WorldHeader { + /// Human-readable name for this world. + name: String, + /// Key of the board to start on; must match a key in `[boards]`. + start: String, +} + +/// Load a world from a `.toml` world file at `path`. +/// +/// Returns an error if the file cannot be read or parsed, if any board fails +/// the hard-error conversion (only a grid-dimension mismatch qualifies — all +/// other problems are recorded on [`Board::load_errors`]), or if [`World::start`] +/// does not match any board key in the file. +pub fn load(path: &str) -> Result> { + let text = std::fs::read_to_string(path)?; + let wf: WorldFile = toml::from_str(&text)?; + + // Convert each MapFile into a Board. Grid-dimension mismatches are the only + // hard errors; everything else is nonfatal and lands on Board::load_errors. + let mut boards = HashMap::new(); + for (key, map_file) in wf.boards { + let board = Board::try_from(map_file) + .map_err(|e| format!("board '{}': {}", key, e))?; + boards.insert(key, Rc::new(RefCell::new(board))); + } + + // Guard: the start key must actually exist in the boards map. + if !boards.contains_key(&wf.world.start) { + return Err(format!( + "start board '{}' not found in world '{}'", + wf.world.start, wf.world.name + ) + .into()); + } + + Ok(World { + name: wf.world.name, + start: wf.world.start, + scripts: wf.scripts, + boards, + }) +} diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 3ec6713..eace6c3 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -49,22 +49,14 @@ fn main() -> ExitCode { }; // Load before touching the terminal so load errors print to a normal screen. - let board = match world::load(&path) { - Ok(mut w) => match w.boards.remove(&w.start) { - Some(b) => b, - None => { - eprintln!("error: start board '{}' not found", w.start); - return ExitCode::FAILURE; - } - }, + let mut game = match world::load(&path) { + Ok(world) => GameState::from_world(world), Err(e) => { eprintln!("error loading {path}: {e}"); return ExitCode::FAILURE; } }; - let mut game = GameState::new(board); - // `init` enters the alternate screen and raw mode; `restore` always undoes it. let mut terminal = ratatui::init(); diff --git a/maps/start.toml b/maps/start.toml index 7ebb93d..d8e10b9 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -2,6 +2,94 @@ name = "Kiln Demo" start = "start" +# Named Rhai scripts shared across all boards in this world. +# Objects reference a script by name via `script_name`. +[scripts] +greeter = """ +fn init() { + log(`hello from object — player at ${Board.player_x}, ${Board.player_y}`); + set_tile(2); // change my glyph to ☻ — proves the write path + say("Hello there,\\ntraveller!"); +} + +fn tick(dt) { + //log(`x: ${Me.x} ${Board.player_x} y: ${Me.y} ${Board.player_y} q: ${Queue.length()}`); + if Board.player_x == Me.x && Board.player_y == Me.y && Queue.length() == 0 { + say("Hey! Get offa me!"); + delay(5.0); + } +} +""" + +mover = """ +// move() costs 250 ms, so the object steps ~4 cells/sec no matter how often +// tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking +// into a wall (or another object's already-queued move this tick). +fn tick(dt) { + if Queue.length() == 0 { + move(East); + } +} +// Fires when something steps into this object; id is the bumper's object id, +// or -1 for the player. +fn bump(id) { + log(`mover bumped by ${id}`); + say("Ow!"); +} +""" + +muffin = """ +fn bump(id) { + scroll([ + "You find a small muffin on the ground, your favorite kind.", + "It smells of cinnamon and warm mornings.", + ["eat", "Pick up the muffin and eat it."], + ["ignore", "This is obviously a trap — ignore it."] + ]); +} +fn eat() { + log("You ate the muffin. Delicious."); + say("Mmm!"); +} +fn ignore() { + log("You walk away from the muffin. Wise."); + say("Suspicious..."); +} +""" + +noticeboard = """ +fn bump(id) { + scroll([ + " TOWN NOTICE BOARD", + " ", + "ROAD CLOSURE — The eastern road through Miller's Crossing remains closed", + "following flood damage to the main bridge. Repairs are underway but travellers", + "should expect delays of at least three weeks. The north fork via Ashwell is", + "passable, though it adds half a day to the journey.", + " ", + "LOST ANIMAL — One grey mule, answers to the name Pepper. Last seen grazing", + "near the old mill on the morning of the 14th. The animal has a notched left", + "ear and wears a blue rope halter. Any information to the innkeeper; a reward", + "of two silver pieces is offered for her safe return.", + " ", + "POSITION AVAILABLE — Captain Aldric of the town garrison seeks an experienced", + "wilderness guide for an expedition into the Darkwood. Duration: ten to fourteen", + "days. Pay is good and provisions will be supplied. Candidates must present", + "themselves at the barracks before the evening bell. Some risk is involved and", + "the captain asks that only those in sound health and good standing apply.", + " ", + "HARVEST FESTIVAL — The annual Harvest Festival will be held on the last", + "Saturday of the month. The market square will be closed to carts from dawn.", + "Stall permits must be obtained from the clerk's office no later than Thursday.", + "There will be music, a pie competition, and the traditional lantern parade at", + "dusk. All residents are warmly encouraged to attend.", + " ", + " Posted by order of the Town Council.", + " Unauthorised notices will be removed.", + ]); +} +""" + [boards.start.map] name = "Starting Room" width = 60 @@ -124,89 +212,3 @@ bg = "#000000" solid = true name = "noticeboard" script_name = "noticeboard" - -[boards.start.scripts] -greeter = """ -fn init() { - log(`hello from object — player at ${Board.player_x}, ${Board.player_y}`); - set_tile(2); // change my glyph to ☻ — proves the write path - say("Hello there,\\ntraveller!"); -} - -fn tick(dt) { - //log(`x: ${Me.x} ${Board.player_x} y: ${Me.y} ${Board.player_y} q: ${Queue.length()}`); - if Board.player_x == Me.x && Board.player_y == Me.y && Queue.length() == 0 { - say("Hey! Get offa me!"); - delay(5.0); - } -} -""" - -mover = """ -// move() costs 250 ms, so the object steps ~4 cells/sec no matter how often -// tick() runs. Queue.length() avoids piling up moves; blocked() avoids walking -// into a wall (or another object's already-queued move this tick). -fn tick(dt) { - if Queue.length() == 0 { - move(East); - } -} -// Fires when something steps into this object; id is the bumper's object id, -// or -1 for the player. -fn bump(id) { - log(`mover bumped by ${id}`); - say("Ow!"); -} -""" - -muffin = """ -fn bump(id) { - scroll([ - "You find a small muffin on the ground, your favorite kind.", - "It smells of cinnamon and warm mornings.", - ["eat", "Pick up the muffin and eat it."], - ["ignore", "This is obviously a trap — ignore it."] - ]); -} -fn eat() { - log("You ate the muffin. Delicious."); - say("Mmm!"); -} -fn ignore() { - log("You walk away from the muffin. Wise."); - say("Suspicious..."); -} -""" - -noticeboard = """ -fn bump(id) { - scroll([ - " TOWN NOTICE BOARD", - " ", - "ROAD CLOSURE — The eastern road through Miller's Crossing remains closed", - "following flood damage to the main bridge. Repairs are underway but travellers", - "should expect delays of at least three weeks. The north fork via Ashwell is", - "passable, though it adds half a day to the journey.", - " ", - "LOST ANIMAL — One grey mule, answers to the name Pepper. Last seen grazing", - "near the old mill on the morning of the 14th. The animal has a notched left", - "ear and wears a blue rope halter. Any information to the innkeeper; a reward", - "of two silver pieces is offered for her safe return.", - " ", - "POSITION AVAILABLE — Captain Aldric of the town garrison seeks an experienced", - "wilderness guide for an expedition into the Darkwood. Duration: ten to fourteen", - "days. Pay is good and provisions will be supplied. Candidates must present", - "themselves at the barracks before the evening bell. Some risk is involved and", - "the captain asks that only those in sound health and good standing apply.", - " ", - "HARVEST FESTIVAL — The annual Harvest Festival will be held on the last", - "Saturday of the month. The market square will be closed to carts from dawn.", - "Stall permits must be obtained from the clerk's office no later than Thursday.", - "There will be music, a pie competition, and the traditional lantern parade at", - "dusk. All residents are warmly encouraged to attend.", - " ", - " Posted by order of the Town Council.", - " Unauthorised notices will be removed.", - ]); -} -"""