object tags

This commit is contained in:
2026-06-07 01:26:18 -05:00
parent 9c2212520c
commit a8d2cb8303
8 changed files with 240 additions and 6 deletions
+4 -2
View File
@@ -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<ObjectId, ObjectDef> = 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;
}
+5
View File
@@ -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); }
}
}
}
}
}
+7
View File
@@ -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<String>,
/// Open-ended string labels. Serialized as a TOML array; omitted when empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>,
}
fn default_true() -> bool {
@@ -497,6 +500,7 @@ impl TryFrom<MapFile> for Board {
}
};
let obj = ObjectDef {
id: 0, // stamped by Board::add_object
x,
y,
glyph: Glyph {
@@ -508,6 +512,7 @@ impl TryFrom<MapFile> 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<String> = o.tags.iter().cloned().collect(); v.sort(); v },
})
.collect();
+13
View File
@@ -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<String>,
/// 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<String>,
}
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(),
}
}
}
+59 -4
View File
@@ -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
}
@@ -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
+1
View File
@@ -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(),
+74
View File
@@ -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() {