diff --git a/CLAUDE.md b/CLAUDE.md index 88471e1..e867c94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `FontSpec { path: String, tile_w: u32, tile_h: u32 }` — optional per-board bitmap font. When `None`, the app default is used. **`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`): -- `ObjectDef` — a scripted tile: `x`, `y`, `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option`. `ObjectDef::new(x, y)` constructs with defaults. `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black. +- `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)`). @@ -96,7 +96,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc **`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). - 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). Each getter briefly borrows the shared `Board`. +- **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)` append an `Action` (`Move`/`SetTile`/`Log`; `Action::time_cost()` is `MOVE_COST = 0.25` s for `Move`, else 0) to the *issuing object's* output queue (routed by the per-call tag via a `HashMap`). `ScriptHost::pump(i)` promotes ready actions onto the shared **board queue** (`Vec`): the leading run of zero-cost actions plus at most one timed action, which arms that object's **ready timer** and ends the pump. While a ready timer is `> 0` nothing is pulled (this caps object speed); `advance_timers(dt)` counts them down each frame. `GameState` drains the board queue with `take_board_queue()` and applies it after the batch — so scripts mutate without a `&mut GameState` borrow. Errors (compile/runtime) bypass the queues via the error sink (`take_errors()`). - The `Queue` object (a handle to the calling object's output queue) is pushed into each scope; scripts call `Queue.length()` and `Queue.clear()`. Safe to mutate from script because the host only touches output queues between calls (during `pump`). - **Sender identity:** `source` (which object issued an action) rides the per-call **tag** — `run` calls `call_fn_with_options(...with_tag(object_id)...)` and the host fns read it via `NativeCallContext::tag()` (decoded back to an `ObjectId` by `source_of`). Scripts write `move(North)` without naming themselves; `North`/`South`/`East`/`West` are `Direction` constants in scope, and `impl From for (i32,i32)` gives the delta. @@ -251,6 +251,7 @@ 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] [[portals]] # optional; parsed but not yet runtime-wired diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index cacb52f..39d9f8e 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -194,6 +194,9 @@ pub(crate) struct MapFileObjectEntry { /// Open-ended string labels. Serialized as a TOML array; omitted when empty. #[serde(default, skip_serializing_if = "Vec::is_empty")] tags: Vec, + /// Optional unique human-readable name. Omitted from TOML when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, } fn default_true() -> bool { @@ -474,6 +477,8 @@ impl TryFrom for Board { // load order, preserving the documented "lowest id wins a collision" rule). let mut objects: BTreeMap = BTreeMap::new(); let mut next_object_id: ObjectId = 1; + // Track claimed names so duplicates can be caught and cleared (nonfatal). + let mut seen_names: HashMap = HashMap::new(); for (i, e) in mf.objects.into_iter().enumerate() { if skip[i] { continue; @@ -499,6 +504,21 @@ impl TryFrom for Board { } } }; + // Validate name uniqueness: first claimant keeps the name, later ones + // have their name cleared and a nonfatal error is recorded. + let name = e.name.and_then(|n| { + use std::collections::hash_map::Entry; + match seen_names.entry(n.clone()) { + Entry::Vacant(v) => { v.insert(i); Some(n) } + Entry::Occupied(o) => { + load_errors.push(LogLine::error(format!( + "object {i}: name {n:?} already used by object {}; clearing name", + o.get() + ))); + None + } + } + }); let obj = ObjectDef { id: 0, // stamped by Board::add_object x, @@ -513,6 +533,7 @@ impl TryFrom for Board { pushable: e.pushable, script_name: e.script_name, tags: e.tags.into_iter().collect(), + name, }; // A solid object may not share a cell with another solid. if obj.solid { @@ -652,6 +673,7 @@ impl From<&Board> for MapFile { script_name: o.script_name.clone(), // Sort for stable TOML output; HashSet iteration order is non-deterministic. tags: { let mut v: Vec = o.tags.iter().cloned().collect(); v.sort(); v }, + name: o.name.clone(), }) .collect(); diff --git a/kiln-core/src/object_def.rs b/kiln-core/src/object_def.rs index 2e5df27..beb41a7 100644 --- a/kiln-core/src/object_def.rs +++ b/kiln-core/src/object_def.rs @@ -51,6 +51,10 @@ pub struct ObjectDef { /// not subject to any rate limit — mutations take effect immediately after /// the frame's action queue is drained. pub tags: HashSet, + /// Optional unique human-readable name for this object. `None` if unnamed. + /// Names are validated for uniqueness at map-load time; a duplicate name is + /// cleared to `None` (the object survives but becomes anonymous). + pub name: Option, } impl ObjectDef { @@ -80,6 +84,7 @@ impl ObjectDef { pushable: false, script_name: None, tags: HashSet::new(), + name: None, } } } \ No newline at end of file diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index bc18dea..1cdb13a 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -466,6 +466,32 @@ fn register_read_api(engine: &mut Engine, board: &BoardRef, board_queue: &BoardQ .collect() }, ); + + // my_name() -> String: the calling object's name, or "" if unnamed. + let b = board.clone(); + engine.register_fn("my_name", move |ctx: NativeCallContext| -> String { + let src = source_of(&ctx); + b.borrow() + .objects + .get(&src) + .and_then(|o| o.name.as_deref()) + .unwrap_or("") + .to_string() + }); + + // object_id_for_name(s) -> i64: id of the object with the given name, or 0 if none. + let b = board.clone(); + engine.register_fn( + "object_id_for_name", + move |_ctx: NativeCallContext, name: ImmutableString| -> i64 { + b.borrow() + .objects + .iter() + .find(|(_, o)| o.name.as_deref() == Some(name.as_str())) + .map(|(&id, _)| id as i64) + .unwrap_or(0) + }, + ); } /// Whether the object at `source` would bump something by moving in `dir`. diff --git a/kiln-core/src/tests/map_file/object_placement.rs b/kiln-core/src/tests/map_file/object_placement.rs index 55b2489..2793486 100644 --- a/kiln-core/src/tests/map_file/object_placement.rs +++ b/kiln-core/src/tests/map_file/object_placement.rs @@ -1,6 +1,23 @@ use crate::archetype::Archetype; use super::{load_board, map_3x1, obj_palette}; +#[test] +fn duplicate_name_clears_second_but_keeps_both_objects() { + // Two objects share the name "gate": first keeps it, second is cleared. + // Both objects still appear on the board; the map is flagged invalid. + let objs = format!( + "{}\n{}", + "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\nname = \"gate\"\n", + "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\nname = \"gate\"\nsolid = false\n", + ); + let board = load_board(&map_3x1("...", &objs)); + assert_eq!(board.objects.len(), 2, "both objects survive"); + // First object (id 1) keeps the name; second (id 2) is cleared. + assert_eq!(board.objects[&1].name.as_deref(), Some("gate")); + assert_eq!(board.objects[&2].name, None); + assert!(!board.is_valid(), "duplicate name is a nonfatal load error"); +} + #[test] fn palette_placement_puts_object_on_empty_floor() { // `G` appears once, isn't a palette key → object lands there, cell is Empty. diff --git a/kiln-core/src/tests/map_file/round_trip.rs b/kiln-core/src/tests/map_file/round_trip.rs index 0919ed9..386985b 100644 --- a/kiln-core/src/tests/map_file/round_trip.rs +++ b/kiln-core/src/tests/map_file/round_trip.rs @@ -2,7 +2,7 @@ use color::Rgba8; use crate::board::Board; use crate::glyph::Glyph; use crate::map_file::{MapFile, parse_color}; -use super::load_board; +use super::{load_board, map_3x1, obj_palette}; #[test] fn object_glyph_round_trips_through_toml() { @@ -128,6 +128,32 @@ bg = "#000000" assert!(!toml_out.contains("tags"), "empty tags must not appear in TOML output"); } +#[test] +fn object_name_round_trips_through_toml() { + // A named object must survive a save→load cycle with its name intact. + let board = load_board(&map_3x1("G..", &obj_palette("G", "name = \"beacon\"\n"))); + assert_eq!(board.objects[&1].name.as_deref(), Some("beacon")); + + let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); + assert!(toml_out.contains("\"beacon\""), "name must appear in saved TOML"); + let board2 = load_board(&toml_out); + assert_eq!(board2.objects[&1].name.as_deref(), Some("beacon")); +} + +#[test] +fn unnamed_object_omits_name_from_toml() { + // An unnamed object must not emit a `name` key in TOML; check via the parsed + // value so the map header's `name = "Test"` doesn't produce a false positive. + let board = load_board(&map_3x1("G..", &obj_palette("G", ""))); + let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); + let val: toml::Value = toml_out.parse().unwrap(); + let objects = val["objects"].as_array().unwrap(); + assert!( + !objects[0].as_table().unwrap().contains_key("name"), + "unnamed object must not emit name key" + ); +} + #[test] fn floor_spec_round_trips_through_toml() { // A 2×2 board with a grid floor (a generator and a fixed glyph) must survive diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 92b2873..6b23722 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -191,6 +191,55 @@ fn objects_with_tag_returns_matching_ids() { assert_eq!(texts[1], "2"); // obj2 is id 2 } +#[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( + Some("n"), + &[("n", r#"fn init() { log(my_name()); }"#)], + ); + board.objects.get_mut(&1).unwrap().name = Some("beacon".to_string()); + let mut game = GameState::new(board); + game.run_init(); + assert_eq!(log_texts(&game), vec!["beacon"]); + + // An unnamed object should get an empty string. + let board2 = board_with_object( + Some("n"), + &[("n", r#"fn init() { log(my_name()); }"#)], + ); + let mut game2 = GameState::new(board2); + game2.run_init(); + assert_eq!(log_texts(&game2), vec![""]); +} + +#[test] +fn object_id_for_name_finds_by_name() { + // A board with two objects; one is named. The querying object uses + // object_id_for_name to find the named one and logs its id. + 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(object_id_for_name("target").to_string()); + log(object_id_for_name("missing").to_string()); + }"#), + ("none", ""), + ], + ); + let mut game = GameState::new(board); + game.run_init(); + let texts = log_texts(&game); + assert_eq!(texts[0], "2"); // obj2 is id 2 + assert_eq!(texts[1], "0"); // 0 = not found +} + // Ensure try_move from the player side also triggers scripted bump #[test] fn player_bump_fires_with_negative_one() {