Added names to objects
This commit is contained in:
@@ -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<String>,
|
||||
/// Optional unique human-readable name. Omitted from TOML when absent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -474,6 +477,8 @@ impl TryFrom<MapFile> for Board {
|
||||
// load order, preserving the documented "lowest id wins a collision" rule).
|
||||
let mut objects: BTreeMap<ObjectId, ObjectDef> = 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<String, usize> = HashMap::new();
|
||||
for (i, e) in mf.objects.into_iter().enumerate() {
|
||||
if skip[i] {
|
||||
continue;
|
||||
@@ -499,6 +504,21 @@ impl TryFrom<MapFile> 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<MapFile> 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<String> = o.tags.iter().cloned().collect(); v.sort(); v },
|
||||
name: o.name.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
impl ObjectDef {
|
||||
@@ -80,6 +84,7 @@ impl ObjectDef {
|
||||
pushable: false,
|
||||
script_name: None,
|
||||
tags: HashSet::new(),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user