object tags
This commit is contained in:
@@ -302,9 +302,10 @@ impl Board {
|
|||||||
///
|
///
|
||||||
/// Ids start at 1 and increase monotonically; an id is never reused, so it
|
/// 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.
|
/// 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;
|
let id = self.next_object_id;
|
||||||
self.next_object_id += 1;
|
self.next_object_id += 1;
|
||||||
|
object.id = id;
|
||||||
self.objects.insert(id, object);
|
self.objects.insert(id, object);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
@@ -332,7 +333,8 @@ pub(crate) mod tests {
|
|||||||
) -> Board {
|
) -> Board {
|
||||||
let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
|
let mut object_map: BTreeMap<ObjectId, ObjectDef> = BTreeMap::new();
|
||||||
let mut next_object_id: ObjectId = 1;
|
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);
|
object_map.insert(next_object_id, o);
|
||||||
next_object_id += 1;
|
next_object_id += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Log(line) => logs.push(line),
|
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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ pub(crate) struct MapFileObjectEntry {
|
|||||||
/// Name of the Rhai script in the `[scripts]` table, if any.
|
/// Name of the Rhai script in the `[scripts]` table, if any.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
script_name: Option<String>,
|
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 {
|
fn default_true() -> bool {
|
||||||
@@ -497,6 +500,7 @@ impl TryFrom<MapFile> for Board {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let obj = ObjectDef {
|
let obj = ObjectDef {
|
||||||
|
id: 0, // stamped by Board::add_object
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
glyph: Glyph {
|
glyph: Glyph {
|
||||||
@@ -508,6 +512,7 @@ impl TryFrom<MapFile> for Board {
|
|||||||
opaque: e.opaque,
|
opaque: e.opaque,
|
||||||
pushable: e.pushable,
|
pushable: e.pushable,
|
||||||
script_name: e.script_name,
|
script_name: e.script_name,
|
||||||
|
tags: e.tags.into_iter().collect(),
|
||||||
};
|
};
|
||||||
// A solid object may not share a cell with another solid.
|
// A solid object may not share a cell with another solid.
|
||||||
if obj.solid {
|
if obj.solid {
|
||||||
@@ -645,6 +650,8 @@ impl From<&Board> for MapFile {
|
|||||||
opaque: o.opaque,
|
opaque: o.opaque,
|
||||||
pushable: o.pushable,
|
pushable: o.pushable,
|
||||||
script_name: o.script_name.clone(),
|
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();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use color::Rgba8;
|
use color::Rgba8;
|
||||||
|
use std::collections::HashSet;
|
||||||
use crate::glyph::Glyph;
|
use crate::glyph::Glyph;
|
||||||
|
use crate::utils::ObjectId;
|
||||||
|
|
||||||
/// A scripted object placed on the board, loaded from a map file.
|
/// 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
|
/// `init()` and `tick(dt)` functions are called via [`GameState::run_init`] and
|
||||||
/// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work.
|
/// [`GameState::tick`]. Other event hooks (touch, shoot, …) are future work.
|
||||||
pub struct ObjectDef {
|
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).
|
/// Column of this object on the board (0-indexed).
|
||||||
pub x: usize,
|
pub x: usize,
|
||||||
/// Row of this object on the board (0-indexed).
|
/// 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.
|
/// Name of the Rhai script in [`Board::scripts`] that drives this object.
|
||||||
/// `None` means this object has no script yet.
|
/// `None` means this object has no script yet.
|
||||||
pub script_name: Option<String>,
|
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 {
|
impl ObjectDef {
|
||||||
@@ -60,6 +71,7 @@ impl ObjectDef {
|
|||||||
/// round-trip correctly.
|
/// round-trip correctly.
|
||||||
pub fn new(x: usize, y: usize) -> Self {
|
pub fn new(x: usize, y: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
id: 0,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
glyph: Self::default_glyph(),
|
glyph: Self::default_glyph(),
|
||||||
@@ -67,6 +79,7 @@ impl ObjectDef {
|
|||||||
opaque: true,
|
opaque: true,
|
||||||
pushable: false,
|
pushable: false,
|
||||||
script_name: None,
|
script_name: None,
|
||||||
|
tags: HashSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+59
-4
@@ -51,6 +51,9 @@ pub enum Action {
|
|||||||
SetTile(u32),
|
SetTile(u32),
|
||||||
/// Append a plain (uncolored) line to the game log.
|
/// Append a plain (uncolored) line to the game log.
|
||||||
Log(LogLine),
|
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 {
|
impl Action {
|
||||||
@@ -61,7 +64,7 @@ impl Action {
|
|||||||
pub fn time_cost(&self) -> f64 {
|
pub fn time_cost(&self) -> f64 {
|
||||||
match self {
|
match self {
|
||||||
Action::Move(_) => MOVE_COST,
|
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 {
|
objects.push(ObjectRuntime {
|
||||||
object_id: id,
|
object_id: id,
|
||||||
script_name: name.clone(),
|
script_name: name.clone(),
|
||||||
scope: new_object_scope(board_cell, &queue),
|
scope: new_object_scope(board_cell, &queue, id),
|
||||||
output_queue: queue,
|
output_queue: queue,
|
||||||
ready_timer: 0.0,
|
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| {
|
engine.register_fn("blocked", move |ctx: NativeCallContext, dir: Direction| {
|
||||||
is_blocked(&b, &bq, source_of(&ctx), dir)
|
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`.
|
/// 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()`
|
/// 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
|
/// 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`).
|
/// object's `Queue`, the four direction constants, and `MY_ID` (the object's own
|
||||||
fn new_object_scope(board: &BoardRef, queue: &ObjQueue) -> Scope<'static> {
|
/// 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();
|
let mut scope = Scope::new();
|
||||||
scope.push_constant("Board", board.clone());
|
scope.push_constant("Board", board.clone());
|
||||||
scope.push_constant("Queue", queue.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("South", Direction::South);
|
||||||
scope.push_constant("East", Direction::East);
|
scope.push_constant("East", Direction::East);
|
||||||
scope.push_constant("West", Direction::West);
|
scope.push_constant("West", Direction::West);
|
||||||
|
scope.push_constant("MY_ID", object_id as i64);
|
||||||
scope
|
scope
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,83 @@ bg = "#000000"
|
|||||||
assert_eq!(obj2.glyph.bg, obj.glyph.bg);
|
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]
|
#[test]
|
||||||
fn floor_spec_round_trips_through_toml() {
|
fn floor_spec_round_trips_through_toml() {
|
||||||
// A 2×2 board with a grid floor (a generator and a fixed glyph) must survive
|
// A 2×2 board with a grid floor (a generator and a fixed glyph) must survive
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::game::GameState;
|
|||||||
/// script, plus the given `(name, source)` script table entries.
|
/// script, plus the given `(name, source)` script table entries.
|
||||||
fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> Board {
|
fn board_with_object(object_script: Option<&str>, scripts: &[(&str, &str)]) -> Board {
|
||||||
let mut object = ObjectDef::new(0, 0);
|
let mut object = ObjectDef::new(0, 0);
|
||||||
|
object.id = 1;
|
||||||
object.script_name = object_script.map(str::to_string);
|
object.script_name = object_script.map(str::to_string);
|
||||||
Board {
|
Board {
|
||||||
name: "test".into(),
|
name: "test".into(),
|
||||||
|
|||||||
@@ -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
|
// Ensure try_move from the player side also triggers scripted bump
|
||||||
#[test]
|
#[test]
|
||||||
fn player_bump_fires_with_negative_one() {
|
fn player_bump_fires_with_negative_one() {
|
||||||
|
|||||||
Reference in New Issue
Block a user