Board layers

This commit is contained in:
2026-06-15 23:35:18 -05:00
parent d1d0824d37
commit 01cd73ca3b
29 changed files with 2166 additions and 2051 deletions
+37 -31
View File
@@ -1,53 +1,59 @@
use super::{layer, load_board, map};
use crate::archetype::Archetype;
use crate::board::Board;
use crate::map_file::MapFile;
use super::minimal_toml;
#[test]
fn grid_wrong_row_count_returns_error() {
// height = 3 but only 2 rows in the grid
let toml = minimal_toml(3, 3, &["...", "..."]);
// height = 3 but only 2 rows in the layer grid.
let toml = map(3, 3, &[layer("...\n...", &[(".", "kind = \"empty\"")])]);
let mf: MapFile = toml::from_str(&toml).unwrap();
let result = Board::try_from(mf);
assert!(result.is_err());
let msg = result.err().unwrap();
assert!(msg.contains("2 rows"), "expected row count in error, got: {msg}");
assert!(msg.contains("height = 3"), "expected declared height in error, got: {msg}");
assert!(
msg.contains("2 rows"),
"expected row count in error, got: {msg}"
);
assert!(
msg.contains("3 tall"),
"expected declared height in error, got: {msg}"
);
}
#[test]
fn grid_wrong_row_width_returns_error() {
// width = 4 but second row is only 3 characters
let toml = minimal_toml(4, 2, &["....", "..."]);
// width = 4 but second row is only 3 characters.
let toml = map(4, 2, &[layer("....\n...", &[(".", "kind = \"empty\"")])]);
let mf: MapFile = toml::from_str(&toml).unwrap();
let result = Board::try_from(mf);
assert!(result.is_err());
let msg = result.err().unwrap();
assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}");
assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}");
assert!(
msg.contains("3 characters"),
"expected col count in error, got: {msg}"
);
assert!(
msg.contains("4 wide"),
"expected declared width in error, got: {msg}"
);
}
#[test]
fn object_archetype_in_palette_produces_error_block() {
// "object" is no longer a valid palette archetype; it should produce ErrorBlock.
// Player is placed away from the O cell so it isn't cleared.
let toml = r##"
[map]
name = "Test"
width = 2
height = 1
player_start = [1, 0]
[palette]
"O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" }
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
[grid]
content = """
O.
"""
"##;
let mf: MapFile = toml::from_str(toml).unwrap();
let board = Board::try_from(mf).unwrap();
assert_eq!(*board.get(0, 0), (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock));
fn unknown_kind_produces_error_block() {
// A palette `kind` that is neither a meta-kind nor a known archetype name
// becomes a visible ErrorBlock. Player placed away from the X cell.
let toml = map(
2,
1,
&[layer(
"X@",
&[("X", "kind = \"frobnicate\""), ("@", "kind = \"player\"")],
)],
);
let board = load_board(&toml);
assert_eq!(
*board.get(0, 0, 0),
(Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)
);
}
+44 -70
View File
@@ -12,79 +12,53 @@ fn load_board(toml: &str) -> Board {
Board::try_from(mf).expect("convert to board")
}
/// A 3×1 map (`empty` palette `.`) whose grid is `grid`, plus the given
/// `[[objects]]` TOML appended. Keeps placement tests compact.
fn map_3x1(grid: &str, objects: &str) -> String {
format!(
r##"
[map]
name = "Test"
width = 3
height = 1
player_start = [2, 0]
[palette]
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
[grid]
content = """
{grid}
"""
{objects}
"##
)
/// Builds one `[[layers]]` block: a triple-quoted `content` grid plus an inline
/// `palette` table. Each palette entry is `(char_key, inline-body)` where the
/// body is the inside of the entry's `{ ... }` (e.g. `kind = "wall"`).
fn layer(content: &str, palette: &[(&str, &str)]) -> String {
let pal = palette
.iter()
.map(|(k, body)| format!("\"{k}\" = {{ {body} }}"))
.collect::<Vec<_>>()
.join(", ");
format!("\n[[layers]]\ncontent = \"\"\"\n{content}\n\"\"\"\npalette = {{ {pal} }}\n")
}
/// A standard `[[objects]]` block placed by palette char `ch`.
fn obj_palette(ch: &str, extra: &str) -> String {
format!(
"[[objects]]\npalette = \"{ch}\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n{extra}"
)
/// Wraps a `[map]` header of the given size around the supplied `[[layers]]`
/// blocks (each produced by [`layer`]).
fn map(width: usize, height: usize, layers: &[String]) -> String {
let mut s = format!("[map]\nname = \"Test\"\nwidth = {width}\nheight = {height}\n");
for l in layers {
s.push_str(l);
}
s
}
/// A 1-row map of the given `width` with a raw `player_start` TOML value
/// (`start`, e.g. `[1, 0]` or `"@"`), grid, and objects block.
fn player_map(width: usize, start: &str, grid: &str, objects: &str) -> String {
format!(
r##"
[map]
name = "Test"
width = {width}
height = 1
player_start = {start}
[palette]
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }}
[grid]
content = """
{grid}
"""
{objects}
"##
)
}
/// Minimal valid TOML with a configurable grid, used as a base for dimension tests.
fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String {
let content = grid_rows.join("\n");
format!(
r##"
[map]
name = "Test"
width = {width}
height = {height}
player_start = [0, 0]
[palette]
"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }}
[grid]
content = """
{content}
"""
"##
/// A 3×1 single-layer map: an `empty`/`wall` palette plus one `object` entry
/// placed by char `ch` (cyan `@` glyph), with `extra` appended to its body. The
/// player is placed at the far-right cell via a second char where room allows;
/// callers that need the player elsewhere build the map directly.
fn map_3x1_object(grid: &str, ch: &str, extra: &str) -> String {
let body = if extra.is_empty() {
"kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"".to_string()
} else {
format!("kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\", {extra}")
};
map(
3,
1,
&[layer(
grid,
&[
(" ", "kind = \"empty\""),
(".", "kind = \"empty\""),
(
"#",
"kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"",
),
("@", "kind = \"player\""),
(ch, &body),
],
)],
)
}
@@ -1,18 +1,40 @@
use super::{layer, load_board, map, map_3x1_object};
use crate::archetype::Archetype;
use super::{load_board, map_3x1, obj_palette};
/// Palette shorthands.
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
const WALL: (&str, &str) = (
"#",
"kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"",
);
/// An object palette entry body with the given `extra` fields appended.
fn obj(extra: &str) -> String {
let base = "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"";
if extra.is_empty() {
base.to_string()
} else {
format!("{base}, {extra}")
}
}
#[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));
// Two object entries share the name "gate": first keeps it, second is cleared.
let board = load_board(&map(
3,
1,
&[layer(
"GH@",
&[
("G", &obj("name = \"gate\"")),
("H", &obj("name = \"gate\", solid = false")),
PLAYER,
],
)],
));
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");
@@ -20,80 +42,76 @@ fn duplicate_name_clears_second_but_keeps_both_objects() {
#[test]
fn palette_placement_puts_object_on_empty_floor() {
// `G` appears once, isn't a palette key → object lands there, cell is Empty.
let board = load_board(&map_3x1("G..", &obj_palette("G", "")));
// `G` lands an object; its cell stays Empty (transparent).
let board = load_board(&map_3x1_object("G.@", "G", ""));
assert_eq!(board.objects.len(), 1);
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
assert_eq!(board.get(0, 0).1, Archetype::Empty);
}
#[test]
fn palette_char_absent_from_grid_drops_object() {
let board = load_board(&map_3x1("...", &obj_palette("G", "")));
assert!(board.objects.is_empty());
assert!(!board.is_valid());
assert_eq!(board.get(0, 0, 0).1, Archetype::Empty);
}
#[test]
fn palette_char_appearing_twice_spawns_two_objects() {
// `G` appears at (0,0) and (1,0); player is at (2,0) so neither G conflicts.
// Two independent ObjectDefs are spawned.
let board = load_board(&map_3x1("GG.", &obj_palette("G", "")));
// `G` appears at (0,0) and (1,0); two independent ObjectDefs are spawned.
let board = load_board(&map_3x1_object("GG@", "G", "solid = false"));
assert_eq!(board.objects.len(), 2);
// IDs are assigned in reading order (left-to-right).
assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0));
assert_eq!((board.objects[&2].x, board.objects[&2].y), (1, 0));
// Both cells load as Empty in the grid.
assert_eq!(board.get(0, 0).1, Archetype::Empty);
assert_eq!(board.get(1, 0).1, Archetype::Empty);
assert!(board.is_valid(), "multiple occurrences are not an error");
}
#[test]
fn palette_char_multi_occurrence_only_first_keeps_name() {
// Two occurrences of a named entry (solid = false to avoid player conflict at (2,0)).
// First instance gets the name; second is anonymous.
let entry = obj_palette("G", "solid = false\nname = \"guard\"\n");
let board = load_board(&map_3x1("GGG", &entry));
// Three occurrences of a named entry: the first keeps the name, the rest don't.
let board = load_board(&map(
4,
1,
&[layer(
"GGG@",
&[("G", &obj("solid = false, name = \"guard\"")), PLAYER],
)],
));
assert_eq!(board.objects.len(), 3);
assert_eq!(board.objects[&1].name.as_deref(), Some("guard"));
assert_eq!(board.objects[&2].name, None);
assert_eq!(board.objects[&3].name, None);
}
#[test]
fn palette_char_reused_by_two_objects_keeps_first() {
// Two objects claim `G`: the first keeps it, the later one is dropped.
let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", ""));
let board = load_board(&map_3x1("G..", &two));
assert_eq!(board.objects.len(), 1);
assert!(!board.is_valid());
}
#[test]
fn palette_char_colliding_with_palette_key_drops_object() {
// `#` is a terrain palette key, so it can't be an object placeholder.
let board = load_board(&map_3x1("#..", &obj_palette("#", "")));
assert!(board.objects.is_empty());
}
#[test]
fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() {
// Solid object at the wall cell (0,0): dropped for conflicting with the wall.
let solid = "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let board = load_board(&map_3x1("#..", solid));
assert!(board.objects.is_empty());
// Solid object stacked on a wall (different layer): dropped for the conflict.
let solid = map(
3,
1,
&[
layer("#.@", &[WALL, EMPTY, PLAYER]),
layer("O..", &[("O", &obj("")), EMPTY]),
],
);
assert!(load_board(&solid).objects.is_empty());
// Same placement but non-solid: kept (it doesn't claim the cell's solidity).
let nonsolid = format!("{solid}solid = false\n");
let board = load_board(&map_3x1("#..", &nonsolid));
assert_eq!(board.objects.len(), 1);
let nonsolid = map(
3,
1,
&[
layer("#.@", &[WALL, EMPTY, PLAYER]),
layer("O..", &[("O", &obj("solid = false")), EMPTY]),
],
);
assert_eq!(load_board(&nonsolid).objects.len(), 1);
}
#[test]
fn second_solid_object_on_a_cell_is_dropped() {
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let board = load_board(&map_3x1("...", &format!("{obj}{obj}")));
// Two solid objects on the same cell across layers: the upper one is dropped.
let board = load_board(&map(
3,
1,
&[
layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY]),
layer(".O.", &[EMPTY, ("O", &obj(""))]),
],
));
assert_eq!(board.objects.len(), 1);
assert!(!board.is_valid());
}
@@ -1,70 +1,81 @@
use super::{layer, load_board, map};
use crate::archetype::Archetype;
use super::{load_board, player_map};
#[test]
fn player_coords_place_the_player() {
let b = load_board(&player_map(3, "[1, 0]", "...", ""));
assert_eq!((b.player.x, b.player.y), (1, 0));
assert!(b.is_valid());
}
/// Palette shorthands shared by these tests.
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
const WALL: (&str, &str) = (
"#",
"kind = \"wall\", tile = 35, fg = \"#808080\", bg = \"#606060\"",
);
#[test]
fn player_char_places_on_empty_floor() {
let b = load_board(&player_map(3, "\"@\"", ".@.", ""));
let b = load_board(&map(3, 1, &[layer(".@.", &[EMPTY, PLAYER])]));
assert_eq!((b.player.x, b.player.y), (1, 0));
assert_eq!(b.get(1, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty);
assert!(b.is_valid());
}
#[test]
fn player_wins_solid_terrain_silently() {
// Player coords land on a wall: the wall is cleared, no error reported.
let b = load_board(&player_map(3, "[0, 0]", "#..", ""));
// Wall on layer 0, player on layer 1 at the same cell: the wall is cleared,
// no error reported.
let b = load_board(&map(
3,
1,
&[layer("#..", &[WALL, EMPTY]), layer("@..", &[PLAYER, EMPTY])],
));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(0, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 0, 0).1, Archetype::Empty); // wall cleared on layer 0
assert!(b.is_valid());
}
#[test]
fn player_wins_against_a_solid_object_silently() {
// A solid object on the player's cell is dropped, silently (player wins).
let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let b = load_board(&player_map(3, "[1, 0]", "...", obj));
// A solid object on the player's cell (different layer) is dropped silently.
let b = load_board(&map(
3,
1,
&[
layer(
".O.",
&[
EMPTY,
(
"O",
"kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"",
),
],
),
layer(".@.", &[EMPTY, PLAYER]),
],
));
assert_eq!((b.player.x, b.player.y), (1, 0));
assert!(b.objects.is_empty());
assert!(b.is_valid());
}
#[test]
fn player_char_is_reserved_from_objects() {
// An object trying to use '@' is dropped; the player is still placed.
let obj = "[[objects]]\npalette = \"@\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n";
let b = load_board(&player_map(3, "\"@\"", "@..", obj));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert!(b.objects.is_empty());
assert!(!b.is_valid());
}
#[test]
fn player_char_appearing_twice_uses_first() {
let b = load_board(&player_map(3, "\"@\"", "@.@", ""));
let b = load_board(&map(3, 1, &[layer("@.@", &[EMPTY, PLAYER])]));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert!(!b.is_valid());
}
#[test]
fn player_char_missing_falls_back_to_origin() {
let b = load_board(&player_map(3, "\"@\"", "...", ""));
let b = load_board(&map(3, 1, &[layer("...", &[EMPTY])]));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert!(!b.is_valid());
}
#[test]
fn player_fallback_to_origin_clears_solid_terrain() {
// The '@' char is absent, so the player falls back to (0, 0) — which holds a
// wall. The player wins its cell: the wall is cleared so one-solid-per-cell
// holds. The fallback is still reported.
let b = load_board(&player_map(3, "\"@\"", "#..", ""));
// No player cell, so the player falls back to (0, 0) — which holds a wall. The
// player wins its cell: the wall is cleared. The fallback is still reported.
let b = load_board(&map(3, 1, &[layer("#..", &[WALL, EMPTY])]));
assert_eq!((b.player.x, b.player.y), (0, 0));
assert_eq!(b.get(0, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 0, 0).1, Archetype::Empty);
assert!(!b.is_valid());
}
@@ -1,47 +1,56 @@
use super::{load_board, map_3x1};
use super::{layer, load_board, map};
fn portal_toml(portals: &str) -> String {
format!(
"{}{}",
map_3x1("...", ""),
portals
)
}
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
#[test]
fn portal_duplicate_name_drops_second() {
let toml = portal_toml(
"[[portals]]\nname = \"a\"\nx = 0\ny = 0\ntarget_map = \"x\"\ntarget_entry = \"b\"\n\
[[portals]]\nname = \"a\"\nx = 1\ny = 0\ntarget_map = \"x\"\ntarget_entry = \"c\"\n",
// Two portal cells share the name "a": the second is dropped.
let board = load_board(&map(
3,
1,
&[layer(
"12@",
&[
(
"1",
"kind = \"portal\", name = \"a\", target_map = \"x\", target_entry = \"b\"",
),
(
"2",
"kind = \"portal\", name = \"a\", target_map = \"x\", target_entry = \"c\"",
),
PLAYER,
],
)],
));
assert_eq!(
board.portals.len(),
1,
"second portal with duplicate name should be dropped"
);
let board = load_board(&toml);
assert_eq!(board.portals.len(), 1, "second portal with duplicate name should be dropped");
assert_eq!(board.portals[0].x, 0);
assert!(!board.is_valid(), "duplicate name should be a load error");
}
#[test]
fn portal_palette_char_places_portal_at_grid_position() {
let toml = format!(
"{}{}",
map_3x1("1..", ""),
"[[portals]]\nname = \"p\"\npalette = \"1\"\ntarget_map = \"x\"\ntarget_entry = \"e\"\n"
);
let board = load_board(&toml);
let board = load_board(&map(
3,
1,
&[layer(
"1.@",
&[
(
"1",
"kind = \"portal\", name = \"p\", target_map = \"x\", target_entry = \"e\"",
),
EMPTY,
PLAYER,
],
)],
));
assert_eq!(board.portals.len(), 1);
assert_eq!(board.portals[0].x, 0);
assert_eq!(board.portals[0].y, 0);
assert_eq!((board.portals[0].x, board.portals[0].y), (0, 0));
assert_eq!(board.portals[0].name, "p");
}
#[test]
fn portal_palette_char_colliding_with_object_is_dropped() {
let toml = format!(
"{}{}{}",
map_3x1("1..", ""),
"[[objects]]\npalette = \"1\"\ntile = 64\nfg = \"#ffffff\"\nbg = \"#000000\"\n",
"[[portals]]\nname = \"p\"\npalette = \"1\"\ntarget_map = \"x\"\ntarget_entry = \"e\"\n"
);
let board = load_board(&toml);
assert_eq!(board.portals.len(), 0, "portal whose palette char is already an object char should be dropped");
}
+103 -159
View File
@@ -1,203 +1,147 @@
use color::Rgba8;
use crate::board::Board;
use super::{layer, load_board, map};
use crate::glyph::Glyph;
use crate::map_file::{MapFile, parse_color};
use super::{load_board, map_3x1, obj_palette};
use color::Rgba8;
const EMPTY: (&str, &str) = (".", "kind = \"empty\"");
const PLAYER: (&str, &str) = ("@", "kind = \"player\"");
/// An object palette entry body with the given `extra` fields appended.
fn obj(extra: &str) -> String {
let base = "kind = \"object\", tile = 64, fg = \"#00FFFF\", bg = \"#000000\"";
if extra.is_empty() {
base.to_string()
} else {
format!("{base}, {extra}")
}
}
/// Round-trips a board through save→load and returns the reloaded board.
fn round_trip(toml: &str) -> crate::board::Board {
let board = load_board(toml);
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
load_board(&toml_out)
}
#[test]
fn object_glyph_round_trips_through_toml() {
// Objects must survive a save→load cycle with their glyph intact.
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 = "#00FFFF"
bg = "#000000"
"##;
let mf: MapFile = toml::from_str(toml).unwrap();
let board = Board::try_from(mf).unwrap();
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
let board = load_board(&toml);
assert_eq!(board.objects.len(), 1);
let obj = &board.objects[&1];
assert_eq!(obj.x, 1);
assert_eq!(obj.y, 1);
assert_eq!(obj.glyph.tile, 64);
assert_eq!(obj.glyph.fg, Rgba8 { r: 0x00, g: 0xFF, b: 0xFF, a: 255 });
assert_eq!(obj.glyph.bg, Rgba8 { r: 0, g: 0, b: 0, a: 255 });
let obj0 = &board.objects[&1];
assert_eq!((obj0.x, obj0.y), (1, 0));
assert_eq!(obj0.glyph.tile, 64);
assert_eq!(
obj0.glyph.fg,
Rgba8 {
r: 0x00,
g: 0xFF,
b: 0xFF,
a: 255
}
);
// Save back to TOML and reload; glyph must survive.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
let mf2: MapFile = toml::from_str(&toml_out).unwrap();
let board2 = Board::try_from(mf2).unwrap();
let board2 = round_trip(&toml);
let obj2 = &board2.objects[&1];
assert_eq!(obj2.glyph.tile, obj.glyph.tile);
assert_eq!(obj2.glyph.fg, obj.glyph.fg);
assert_eq!(obj2.glyph.bg, obj.glyph.bg);
assert_eq!(obj2.glyph.tile, obj0.glyph.tile);
assert_eq!(obj2.glyph.fg, obj0.glyph.fg);
assert_eq!(obj2.glyph.bg, obj0.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]
let toml = map(
3,
1,
&[layer(
"@O.",
&[PLAYER, ("O", &obj("tags = [\"enemy\", \"boss\"]")), EMPTY],
)],
);
let board = load_board(&toml);
let obj0 = &board.objects[&1];
assert!(obj0.tags.contains("enemy") && obj0.tags.contains("boss"));
assert_eq!(obj0.tags.len(), 2);
[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.
// Saved tags must be sorted alphabetically (boss before enemy).
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);
let boss = toml_out.find("\"boss\"").expect("boss in TOML");
let enemy = toml_out.find("\"enemy\"").expect("enemy in TOML");
assert!(boss < enemy, "tags must be sorted: boss before enemy");
let board2 = load_board(&toml_out);
assert_eq!(board2.objects[&1].tags, obj0.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 = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
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");
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")));
let toml = map(
3,
1,
&[layer(
"@O.",
&[PLAYER, ("O", &obj("name = \"beacon\"")), EMPTY],
)],
);
let board = load_board(&toml);
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");
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"
fn unnamed_object_name_stays_none_through_toml() {
let toml = map(3, 1, &[layer("@O.", &[PLAYER, ("O", &obj("")), EMPTY])]);
let board2 = round_trip(&toml);
assert_eq!(
board2.objects[&1].name, None,
"unnamed object must round-trip as None"
);
}
#[test]
fn floor_spec_round_trips_through_toml() {
// A 2×2 board with a grid floor (a generator and a fixed glyph) must survive
// save→load with its `[floor]` declaration intact, and the reloaded board's
// computed floor must place the fixed glyph back at its declared cell.
let toml = r##"
[map]
name = "Test"
width = 2
height = 2
player_start = [0, 0]
[palette]
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
[grid]
content = """
..
..
"""
[floor]
content = """
g.
.g
"""
[floor.palette]
"g" = "grass"
"." = { tile = "#", fg = "#010203", bg = "#040506" }
"##;
let board = load_board(toml);
assert!(board.floor_spec.is_some());
fn fixed_floor_glyph_round_trips_through_toml() {
// A fixed floor glyph (visual-only Empty cell) must survive save→load.
let toml = map(
3,
1,
&[layer(
"@F.",
&[
PLAYER,
(
"F",
"kind = \"floor\", tile = \"#\", fg = \"#010203\", bg = \"#040506\"",
),
EMPTY,
],
)],
);
let fixed = Glyph {
tile: '#' as u32,
fg: parse_color("#010203"),
bg: parse_color("#040506"),
};
// (1,0) is the fixed `.` floor glyph (the cell archetype is Empty).
let board = load_board(&toml);
assert_eq!(board.glyph_at(1, 0), fixed);
// Save back to TOML and reload; the floor declaration must be preserved.
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
let board2 = load_board(&toml_out);
assert!(board2.floor_spec.is_some());
let board2 = round_trip(&toml);
assert_eq!(board2.glyph_at(1, 0), fixed);
}