diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index f8c0340..1ed4e6f 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -302,9 +302,10 @@ impl Board { /// /// Ids start at 1 and increase monotonically; an id is never reused, so it /// stays a valid handle to this object for the board's lifetime. - pub fn add_object(&mut self, object: ObjectDef) -> ObjectId { + pub fn add_object(&mut self, mut object: ObjectDef) -> ObjectId { let id = self.next_object_id; self.next_object_id += 1; + object.id = id; self.objects.insert(id, object); id } @@ -332,7 +333,8 @@ pub(crate) mod tests { ) -> Board { let mut object_map: BTreeMap = BTreeMap::new(); let mut next_object_id: ObjectId = 1; - for o in objects { + for mut o in objects { + o.id = next_object_id; object_map.insert(next_object_id, o); next_object_id += 1; } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 258b889..b137360 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -113,6 +113,11 @@ impl GameState { } } Action::Log(line) => logs.push(line), + Action::SetTag { target, tag, present } => { + if let Some(obj) = board.objects.get_mut(&target) { + if present { obj.tags.insert(tag); } else { obj.tags.remove(&tag); } + } + } } } } diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 985dced..8cc06c0 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -191,6 +191,9 @@ pub(crate) struct MapFileObjectEntry { /// Name of the Rhai script in the `[scripts]` table, if any. #[serde(default, skip_serializing_if = "Option::is_none")] script_name: Option, + /// Open-ended string labels. Serialized as a TOML array; omitted when empty. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tags: Vec, } fn default_true() -> bool { @@ -497,6 +500,7 @@ impl TryFrom for Board { } }; let obj = ObjectDef { + id: 0, // stamped by Board::add_object x, y, glyph: Glyph { @@ -508,6 +512,7 @@ impl TryFrom for Board { opaque: e.opaque, pushable: e.pushable, script_name: e.script_name, + tags: e.tags.into_iter().collect(), }; // A solid object may not share a cell with another solid. if obj.solid { @@ -645,6 +650,8 @@ impl From<&Board> for MapFile { opaque: o.opaque, pushable: o.pushable, 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 }, }) .collect(); diff --git a/kiln-core/src/object_def.rs b/kiln-core/src/object_def.rs index 37368f8..2e5df27 100644 --- a/kiln-core/src/object_def.rs +++ b/kiln-core/src/object_def.rs @@ -1,5 +1,7 @@ use color::Rgba8; +use std::collections::HashSet; use crate::glyph::Glyph; +use crate::utils::ObjectId; /// A scripted object placed on the board, loaded from a map file. /// @@ -21,6 +23,11 @@ use crate::glyph::Glyph; /// `init()` and `tick(dt)` functions are called via [`GameState::run_init`] and /// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work. pub struct ObjectDef { + /// Stable identity assigned by [`crate::board::Board::add_object`]. + /// `0` is the sentinel meaning "not yet inserted into a board". + /// Real ids start at 1 and never change after assignment. + /// Not serialized — the id is stamped on insert, not stored in map files. + pub id: ObjectId, /// Column of this object on the board (0-indexed). pub x: usize, /// Row of this object on the board (0-indexed). @@ -40,6 +47,10 @@ pub struct ObjectDef { /// Name of the Rhai script in [`Board::scripts`] that drives this object. /// `None` means this object has no script yet. pub script_name: Option, + /// Open-ended string labels for this object. Serialized as a TOML array; + /// not subject to any rate limit — mutations take effect immediately after + /// the frame's action queue is drained. + pub tags: HashSet, } impl ObjectDef { @@ -60,6 +71,7 @@ impl ObjectDef { /// round-trip correctly. pub fn new(x: usize, y: usize) -> Self { Self { + id: 0, x, y, glyph: Self::default_glyph(), @@ -67,6 +79,7 @@ impl ObjectDef { opaque: true, pushable: false, script_name: None, + tags: HashSet::new(), } } } \ No newline at end of file diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 8d997cd..743a3f4 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -51,6 +51,9 @@ pub enum Action { SetTile(u32), /// Append a plain (uncolored) line to the game log. Log(LogLine), + /// Add (`present = true`) or remove (`present = false`) `tag` on `target`. + /// Zero-cost; applied by [`crate::game::GameState`] after the action batch. + SetTag { target: ObjectId, tag: String, present: bool }, } impl Action { @@ -61,7 +64,7 @@ impl Action { pub fn time_cost(&self) -> f64 { match self { Action::Move(_) => MOVE_COST, - Action::SetTile(_) | Action::Log(_) => 0.0, + Action::SetTile(_) | Action::Log(_) | Action::SetTag { .. } => 0.0, } } } @@ -238,7 +241,7 @@ impl ScriptHost { objects.push(ObjectRuntime { object_id: id, script_name: name.clone(), - scope: new_object_scope(board_cell, &queue), + scope: new_object_scope(board_cell, &queue, id), output_queue: queue, ready_timer: 0.0, }); @@ -422,6 +425,38 @@ fn register_read_api(engine: &mut Engine, board: &BoardRef, board_queue: &BoardQ engine.register_fn("blocked", move |ctx: NativeCallContext, dir: Direction| { is_blocked(&b, &bq, source_of(&ctx), dir) }); + + // has_tag(s) -> bool: does the calling object have this tag? + let b = board.clone(); + engine.register_fn("has_tag", move |ctx: NativeCallContext, tag: ImmutableString| { + let src = source_of(&ctx); + b.borrow().objects.get(&src).map_or(false, |o| o.tags.contains(tag.as_str())) + }); + + // get_tags() -> Array of strings: the calling object's current tag set. + let b = board.clone(); + engine.register_fn("get_tags", move |ctx: NativeCallContext| -> rhai::Array { + let src = source_of(&ctx); + b.borrow() + .objects + .get(&src) + .map(|o| o.tags.iter().map(|t| rhai::Dynamic::from(t.clone())).collect()) + .unwrap_or_default() + }); + + // objects_with_tag(s) -> Array of i64: ids of all objects carrying the tag. + let b = board.clone(); + engine.register_fn( + "objects_with_tag", + move |_ctx: NativeCallContext, tag: ImmutableString| -> rhai::Array { + b.borrow() + .objects + .iter() + .filter(|(_, o)| o.tags.contains(tag.as_str())) + .map(|(&id, _)| rhai::Dynamic::from(id as i64)) + .collect() + }, + ); } /// Whether the object at `source` would bump something by moving in `dir`. @@ -489,6 +524,24 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { ); }, ); + + // set_tag(target_id, tag, present): add (true) or remove (false) a tag on any object. + // Use MY_ID as target_id to mutate the calling object's own tags. + let q = queues.clone(); + engine.register_fn( + "set_tag", + move |ctx: NativeCallContext, target: i64, tag: ImmutableString, present: bool| { + emit( + &q, + source_of(&ctx), + Action::SetTag { + target: target as ObjectId, + tag: tag.to_string(), + present, + }, + ); + }, + ); } /// Registers the `Queue` object: a handle to an object's output queue with `length()` @@ -519,8 +572,9 @@ fn source_of(ctx: &NativeCallContext) -> ObjectId { } /// Builds a fresh per-object scope, seeded with the read-only `Board` view, this -/// object's `Queue`, and the four direction constants (`North`/`South`/`East`/`West`). -fn new_object_scope(board: &BoardRef, queue: &ObjQueue) -> Scope<'static> { +/// object's `Queue`, the four direction constants, and `MY_ID` (the object's own +/// stable [`ObjectId`] as `i64`, for use with `set_tag` and similar calls). +fn new_object_scope(board: &BoardRef, queue: &ObjQueue, object_id: ObjectId) -> Scope<'static> { let mut scope = Scope::new(); scope.push_constant("Board", board.clone()); scope.push_constant("Queue", queue.clone()); @@ -528,5 +582,6 @@ fn new_object_scope(board: &BoardRef, queue: &ObjQueue) -> Scope<'static> { scope.push_constant("South", Direction::South); scope.push_constant("East", Direction::East); scope.push_constant("West", Direction::West); + scope.push_constant("MY_ID", object_id as i64); scope } diff --git a/kiln-core/src/tests/map_file/round_trip.rs b/kiln-core/src/tests/map_file/round_trip.rs index 7335b9b..0919ed9 100644 --- a/kiln-core/src/tests/map_file/round_trip.rs +++ b/kiln-core/src/tests/map_file/round_trip.rs @@ -51,6 +51,83 @@ bg = "#000000" assert_eq!(obj2.glyph.bg, obj.glyph.bg); } +#[test] +fn object_tags_round_trip_through_toml() { + // Tags declared in the map file must survive a save→load cycle, sorted. + let toml = r##" +[map] +name = "Test" +width = 3 +height = 3 +player_start = [0, 0] + +[palette] +"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } + +[grid] +content = """ +... +... +... +""" + +[[objects]] +x = 1 +y = 1 +tile = 64 +fg = "#ffffff" +bg = "#000000" +tags = ["enemy", "boss"] +"##; + let mf: MapFile = toml::from_str(toml).unwrap(); + let board = Board::try_from(mf).unwrap(); + let obj = &board.objects[&1]; + assert!(obj.tags.contains("enemy")); + assert!(obj.tags.contains("boss")); + assert_eq!(obj.tags.len(), 2); + + // Save and reload; tags must survive, and be sorted alphabetically. + let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); + // Both tags must appear; "boss" must come before "enemy" (sorted). + let boss_pos = toml_out.find("\"boss\"").expect("boss in TOML"); + let enemy_pos = toml_out.find("\"enemy\"").expect("enemy in TOML"); + assert!(boss_pos < enemy_pos, "tags must be sorted: boss before enemy"); + let mf2: MapFile = toml::from_str(&toml_out).unwrap(); + let board2 = Board::try_from(mf2).unwrap(); + let obj2 = &board2.objects[&1]; + assert_eq!(obj2.tags, obj.tags); +} + +#[test] +fn object_empty_tags_omitted_from_toml() { + // An object with no tags must not emit a `tags` key in TOML. + let toml = r##" +[map] +name = "Test" +width = 1 +height = 1 +player_start = [0, 0] + +[palette] +"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" } + +[grid] +content = """ +. +""" + +[[objects]] +x = 0 +y = 0 +tile = 64 +fg = "#ffffff" +bg = "#000000" +"##; + let board = load_board(toml); + let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap(); + assert!(!toml_out.contains("tags"), "empty tags must not appear in TOML output"); +} + #[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/mod.rs b/kiln-core/src/tests/mod.rs index 25daad1..7ff3058 100644 --- a/kiln-core/src/tests/mod.rs +++ b/kiln-core/src/tests/mod.rs @@ -15,6 +15,7 @@ use crate::game::GameState; /// script, plus the given `(name, source)` script table entries. fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> Board { let mut object = ObjectDef::new(0, 0); + object.id = 1; object.script_name = object_script.map(str::to_string); Board { name: "test".into(), diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index a5e3e93..92b2873 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -117,6 +117,80 @@ fn start_map_greeter_runs_init() { ); } +#[test] +fn set_tag_adds_and_removes_via_my_id() { + // A script calls set_tag(MY_ID, "active", true) in init; the tag must be + // present on the object afterward. + let board = board_with_object( + Some("t"), + &[("t", r#"fn init() { set_tag(MY_ID, "active", true); }"#)], + ); + let mut game = GameState::new(board); + game.run_init(); + assert!(game.board().objects[&1].tags.contains("active")); + + // A script removes a pre-existing tag. + let board2 = board_with_object( + Some("t2"), + &[("t2", r#"fn init() { set_tag(MY_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 + }); + game2.run_init(); + assert!(!game2.board().objects[&1].tags.contains("active")); +} + +#[test] +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( + Some("t"), + &[( + "t", + r#"fn init() { set_tag(MY_ID, "active", true); } + fn tick(dt) { log(has_tag("active").to_string()); }"#, + )], + ); + let mut game = GameState::new(board); + game.run_init(); + game.tick(Duration::from_millis(16)); + assert!(log_texts(&game).iter().any(|t| t == "true")); +} + +#[test] +fn objects_with_tag_returns_matching_ids() { + // A 5×1 board with two objects; one has tag "enemy". The script on the first + // object queries objects_with_tag("enemy") and logs the ids it finds. + let obj1 = scripted_object(0, 0, "q"); + 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 ids = objects_with_tag("enemy"); + log(ids.len().to_string()); + log(ids[0].to_string()); + }"#), + ("none", ""), + ], + ); + let mut game = GameState::new(board); + game.run_init(); + let texts = log_texts(&game); + assert_eq!(texts[0], "1"); // exactly one match + assert_eq!(texts[1], "2"); // obj2 is id 2 +} + // Ensure try_move from the player side also triggers scripted bump #[test] fn player_bump_fires_with_negative_one() {