use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, Player, PortalDef}; use color::Rgba8; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::convert::TryFrom; use std::path::Path; /// The top-level serialization/deserialization type for a `.toml` map file. /// /// This struct mirrors the TOML file structure exactly. On load it is /// immediately converted into a [`Board`] via [`TryFrom`] and then /// discarded. On save a [`Board`] is converted back into this struct via /// [`From<&Board>`] before serialization. /// /// See `maps/start.toml` for a complete example of the file format. #[derive(Deserialize, Serialize)] pub struct MapFile { /// The `[map]` section: name, dimensions, player start position. pub map: MapHeader, /// The `[palette]` section: maps single-character keys to tile definitions. pub palette: HashMap, /// The `[grid]` section: the multi-line string that defines cell layout. pub grid: GridData, /// Optional `[font]` section specifying a bitmap font for this board. #[serde(default, skip_serializing_if = "Option::is_none")] pub font: Option, /// Any `[[objects]]` entries. Optional; defaults to empty. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) objects: Vec, /// Any `[[portals]]` entries. Optional; defaults to empty. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub portals: Vec, /// The `[scripts]` table: maps script names to Rhai source text. /// Optional; defaults to empty. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub scripts: HashMap, } /// The `[map]` header section of a map file. #[derive(Deserialize, Serialize)] pub struct MapHeader { /// Human-readable name for this board, e.g. `"Opening Room"`. pub name: String, /// Width of the board in cells. Must match the length of every row in `[grid] content`. pub width: usize, /// Height of the board in cells. Must match the number of rows in `[grid] content`. pub height: usize, /// Starting position of the player as `[x, y]` (0-indexed, origin top-left). pub player_start: [i32; 2], /// Name of the board-level script in the `[scripts]` table, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub board_script_name: Option, /// Integer tile scale factor. 1 = natural size. Omitted from files when 1 (the default). #[serde(default = "default_zoom", skip_serializing_if = "is_zoom_default")] pub zoom: u32, } /// The `[font]` section of a map file, specifying a bitmap font for this board. /// /// When absent, the app's default embedded CP437 font is used. #[derive(Deserialize, Serialize)] pub struct FontHeader { /// Path to the PNG font image, relative to the working directory. pub path: String, /// Width of each tile in the font image, in pixels. pub tile_w: u32, /// Height of each tile in the font image, in pixels. pub tile_h: u32, } /// A tile index in a palette entry: either a plain integer or a character literal. /// /// Accepting both forms lets map files use `tile = 32` for new files or /// `tile = " "` for convenience (the char is converted to its Unicode scalar). /// This means existing maps that used `ch = "#"` can be migrated by renaming /// the key to `tile`; the char form keeps working. #[derive(Deserialize, Serialize)] #[serde(untagged)] pub(crate) enum TileIndex { /// A direct tile index (e.g. `tile = 35`). Num(u32), /// A single-character shorthand (e.g. `tile = "#"`); converted to its Unicode scalar. Chr(char), } impl TileIndex { pub(crate) fn into_u32(self) -> u32 { match self { TileIndex::Num(n) => n, TileIndex::Chr(c) => c as u32, } } } /// One entry in the `[palette]` table. /// /// Each entry is keyed by a single character (e.g. `"#"` or `" "`). /// That character is used in the `[grid]` content string to place tiles. /// The entry defines both how the tile looks (`tile`, `fg`, `bg`) and which /// [`Archetype`] it uses for behavior. /// /// `tile` accepts either an integer (`tile = 35`) or a char (`tile = "#"`). #[derive(Deserialize, Serialize)] pub struct PaletteEntry { /// The archetype name for this tile, e.g. `"wall"` or `"empty"`. pub archetype: String, /// The tile index into the board's bitmap font. /// Accepts either an integer or a single-character string. tile: TileIndex, /// Foreground color as an `"#RRGGBB"` hex string. pub fg: String, /// Background color as an `"#RRGGBB"` hex string. pub bg: String, } /// The `[grid]` section of a map file. /// /// `content` is a TOML multi-line string. Each line is one row of the board; /// each character in a line is looked up in the palette to determine the /// tile for that cell. #[derive(Deserialize, Serialize)] pub struct GridData { /// The raw grid content. Split by [`str::lines`] during loading. pub content: String, } /// One `[[objects]]` entry in a map file. /// /// Used only for TOML serialization/deserialization. Converted to/from /// [`ObjectDef`] (which holds a [`Glyph`] directly) in the [`TryFrom`] / /// [`From`] impls below. #[derive(Deserialize, Serialize)] pub(crate) struct MapFileObjectEntry { /// Column of this object on the board (0-indexed). Mutually exclusive with /// `palette`: provide either `x`/`y` or `palette`, not both. #[serde(default, skip_serializing_if = "Option::is_none")] x: Option, /// Row of this object on the board (0-indexed). See `x`. #[serde(default, skip_serializing_if = "Option::is_none")] y: Option, /// Single grid character marking where this object sits, as an alternative to /// `x`/`y`. By convention an uppercase letter. The char must appear exactly /// once in the grid, be unique across objects, and not collide with a /// `[palette]` key; the cell under it is loaded as `Empty`. #[serde(default, skip_serializing_if = "Option::is_none")] palette: Option, /// Tile index into the board's bitmap font. Accepts integer or single-char string. tile: TileIndex, /// Foreground color as an `"#RRGGBB"` hex string. fg: String, /// Background color as an `"#RRGGBB"` hex string. bg: String, /// Whether the object blocks / participates in movement. Defaults to `true` /// (blocking). Inverse of the old `passable` field. #[serde(default = "default_true")] solid: bool, /// Whether the object blocks player line of sight / FOV #[serde(default = "default_true")] opaque: bool, /// Whether the object can be shoved by a mover. Unused for now; defaults `false`. #[serde(default)] pushable: bool, /// Name of the Rhai script in the `[scripts]` table, if any. #[serde(default, skip_serializing_if = "Option::is_none")] script_name: Option, } fn default_true() -> bool { true } fn default_zoom() -> u32 { 1 } fn is_zoom_default(z: &u32) -> bool { *z == 1 } /// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`]. /// Returns opaque black on any parse failure. fn parse_color(hex: &str) -> Rgba8 { let hex = hex.trim_start_matches('#'); if hex.len() != 6 { return Rgba8 { r: 0, g: 0, b: 0, a: 255, }; } let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); Rgba8 { r, g, b, a: 255 } } /// Converts an [`Rgba8`] to an `"#RRGGBB"` hex string (alpha is ignored). fn color_to_hex(color: Rgba8) -> String { format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b) } /// Converts a parsed map file into a runtime [`Board`]. /// /// Returns `Err(String)` if the grid dimensions do not match the header: /// - wrong number of rows (actual row count ≠ `height`) /// - any row whose character count ≠ `width` /// /// The conversion proceeds in two passes: /// /// 1. **Palette pass** — each entry is parsed into a `(Glyph, Archetype)` pair /// and stored in a temporary `HashMap` keyed by the palette character. /// Unknown archetype names produce an [`Archetype::ErrorBlock`] and a logged /// warning so malformed maps are visible in-game. /// /// 2. **Grid pass** — `grid.content` is split into lines; each character is /// looked up in the palette map and pushed directly into `cells`. impl TryFrom for Board { type Error = String; fn try_from(mf: MapFile) -> Result { let w = mf.map.width; let h = mf.map.height; // Pass 1: build a char → (Glyph, Archetype) lookup from the palette. let mut palette: HashMap = HashMap::new(); for (key, entry) in mf.palette { let key_char = key.chars().next().unwrap_or(' '); let tile = entry.tile.into_u32(); match Archetype::try_from(entry.archetype.as_str()) { Ok(archetype) => { let glyph = Glyph { tile, fg: parse_color(&entry.fg), bg: parse_color(&entry.bg), }; palette.insert(key_char, (glyph, archetype)); } Err(e) => { eprintln!("{e}"); palette.insert( key_char, (Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock), ); } } } // Validate grid dimensions before walking cells. // Collect lines eagerly so we can check the count and reuse them. let rows: Vec<&str> = mf.grid.content.lines().collect(); if rows.len() != h { return Err(format!( "grid has {} rows but map header declares height = {}", rows.len(), h )); } for (i, line) in rows.iter().enumerate() { let col_count = line.chars().count(); if col_count != w { return Err(format!( "grid row {} has {} characters but map header declares width = {}", i, col_count, w )); } } // Validate object `palette` references before the grid pass, since the // grid pass needs to know which chars are object placeholders. // `skip[i]` drops object `i`; `palette_char_owner` maps a valid object // palette char to the single object that owns it. let mut skip = vec![false; mf.objects.len()]; let mut palette_char_owner: HashMap = HashMap::new(); for (i, e) in mf.objects.iter().enumerate() { let Some(p) = e.palette.as_deref() else { continue; }; // `palette` is mutually exclusive with explicit `x`/`y`. if e.x.is_some() || e.y.is_some() { eprintln!("object {i}: specify either x/y or palette, not both; skipping object"); skip[i] = true; continue; } // It must be exactly one character. let mut chs = p.chars(); let (Some(ch), None) = (chs.next(), chs.next()) else { eprintln!( "object {i}: palette must be a single character (got {p:?}); skipping object" ); skip[i] = true; continue; }; // It can't reuse a terrain palette key. if palette.contains_key(&ch) { eprintln!( "object {i}: palette char '{ch}' is already a [palette] key; skipping object" ); skip[i] = true; continue; } // It must be unique across objects — a clash drops both owners. use std::collections::hash_map::Entry; match palette_char_owner.entry(ch) { Entry::Occupied(o) => { eprintln!( "object palette char '{ch}' is used by more than one object; skipping both" ); skip[i] = true; skip[*o.get()] = true; } Entry::Vacant(v) => { v.insert(i); } } } // Pass 2: walk the validated grid and build cells. Object palette chars // become `Empty` floor (with their position remembered); truly unknown // chars become a visible `ErrorBlock` so the cell count stays correct. let canonical_empty = (Archetype::Empty.default_glyph(), Archetype::Empty); let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h); let mut obj_palette_pos: HashMap = HashMap::new(); let mut obj_palette_count: HashMap = HashMap::new(); for (y, line) in rows.iter().enumerate() { for (x, ch) in line.chars().enumerate() { if let Some(&cell) = palette.get(&ch) { cells.push(cell); } else if palette_char_owner.contains_key(&ch) { cells.push(canonical_empty); obj_palette_pos.insert(ch, (x, y)); *obj_palette_count.entry(ch).or_insert(0) += 1; } else { eprintln!("unknown grid character '{ch}' at ({x}, {y}); using error block"); cells.push((Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)); } } } // Each surviving object palette char must appear in the grid exactly once. for (&ch, &owner) in &palette_char_owner { if skip[owner] { continue; } let n = obj_palette_count.get(&ch).copied().unwrap_or(0); if n != 1 { eprintln!( "object palette char '{ch}' appears {n} times in the grid \ (must be exactly once); skipping object" ); skip[owner] = true; } } let font = mf.font.map(|f| FontSpec { path: f.path, tile_w: f.tile_w, tile_h: f.tile_h, }); // Convert MapFileObjectEntry → ObjectDef, enforcing one solid per cell. // `solid_occupied` is seeded from the grid (solid archetypes); each solid // object then claims its cell, and a second solid on a cell is dropped. let mut solid_occupied: Vec = cells.iter().map(|(_, a)| a.behavior().solid).collect(); let mut objects: Vec = Vec::new(); for (i, e) in mf.objects.into_iter().enumerate() { if skip[i] { continue; } // Resolve the object's cell from either its palette char or x/y. let (x, y) = if let Some(p) = e.palette.as_deref() { // Single-char and uniqueness already validated; position recorded. obj_palette_pos[&p.chars().next().unwrap()] } else { match (e.x, e.y) { (Some(x), Some(y)) if x < w && y < h => (x, y), (Some(x), Some(y)) => { eprintln!("object {i}: x/y ({x}, {y}) out of bounds; skipping object"); continue; } _ => { eprintln!( "object {i}: must specify both x and y (or a palette char); skipping object" ); continue; } } }; let obj = ObjectDef { x, y, glyph: Glyph { tile: e.tile.into_u32(), fg: parse_color(&e.fg), bg: parse_color(&e.bg), }, solid: e.solid, opaque: e.opaque, pushable: e.pushable, script_name: e.script_name, }; // A solid object may not share a cell with another solid. if obj.solid { let idx = y * w + x; if solid_occupied[idx] { eprintln!( "object {i}: solid object at ({x}, {y}) conflicts with an existing solid; skipping object" ); continue; } solid_occupied[idx] = true; } objects.push(obj); } Ok(Board { name: mf.map.name, width: w, height: h, cells, player: Player { x: mf.map.player_start[0], y: mf.map.player_start[1], }, objects, portals: mf.portals, font, zoom: mf.map.zoom, scripts: mf.scripts, board_script_name: mf.map.board_script_name, }) } } /// Converts a runtime [`Board`] back into a serializable [`MapFile`]. /// /// The palette is reconstructed by enumerating unique `(Glyph, Archetype)` /// combinations in the board's cells and assigning each a single printable /// ASCII character key. The grid is rebuilt by looking up each cell in the /// resulting palette map. impl From<&Board> for MapFile { fn from(board: &Board) -> Self { // Pool for non-empty archetypes: printable ASCII 33-126 ('!' onward), // excluding `"` and `\` which would require escaping inside TOML strings. // Space (32) is reserved exclusively for Archetype::Empty. let pool: Vec = (33u8..=126u8) .filter(|&b| b != b'"' && b != b'\\') .map(|b| b as char) .collect(); let mut pool_iter = pool.iter(); // The canonical empty cell (tile 32, black/black) is always serialized as ' '. // Every other unique (Glyph, Archetype) pair draws from the pool above. let canonical_empty = (Archetype::Empty.default_glyph(), Archetype::Empty); let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new(); let mut palette: HashMap = HashMap::new(); for y in 0..board.height { for x in 0..board.width { let (glyph, arch) = board.get(x, y); let key = (*glyph, *arch); if !cell_to_key.contains_key(&key) { if key == canonical_empty { cell_to_key.insert(key, ' '); palette.insert( " ".to_string(), PaletteEntry { archetype: arch.name().to_string(), tile: TileIndex::Num(glyph.tile), fg: color_to_hex(glyph.fg), bg: color_to_hex(glyph.bg), }, ); } else { let ch = *pool_iter .next() .expect("board has more than 92 unique non-canonical cell types"); cell_to_key.insert(key, ch); palette.insert( ch.to_string(), PaletteEntry { archetype: arch.name().to_string(), tile: TileIndex::Num(glyph.tile), fg: color_to_hex(glyph.fg), bg: color_to_hex(glyph.bg), }, ); } } } } // Pass 2: reconstruct the grid string row by row. let mut rows: Vec = Vec::with_capacity(board.height); for y in 0..board.height { let mut row = String::with_capacity(board.width); for x in 0..board.width { let (glyph, arch) = board.get(x, y); row.push(cell_to_key[&(*glyph, *arch)]); } rows.push(row); } // Trailing newline ensures the last row is complete; str::lines handles it correctly. let content = rows.join("\n") + "\n"; let font = board.font.as_ref().map(|f| FontHeader { path: f.path.clone(), tile_w: f.tile_w, tile_h: f.tile_h, }); // Convert ObjectDef → MapFileObjectEntry, encoding the glyph as TOML-compatible fields. let objects: Vec = board .objects .iter() .map(|o| MapFileObjectEntry { // Saving always emits concrete coordinates; the palette form is a // load-time convenience only. x: Some(o.x), y: Some(o.y), palette: None, tile: TileIndex::Num(o.glyph.tile), fg: color_to_hex(o.glyph.fg), bg: color_to_hex(o.glyph.bg), solid: o.solid, opaque: o.opaque, pushable: o.pushable, script_name: o.script_name.clone(), }) .collect(); let portals = board .portals .iter() .map(|p| PortalDef { x: p.x, y: p.y, target_map: p.target_map.clone(), target_entry: p.target_entry.clone(), }) .collect(); MapFile { map: MapHeader { name: board.name.clone(), width: board.width, height: board.height, player_start: [board.player.x, board.player.y], board_script_name: board.board_script_name.clone(), zoom: board.zoom, }, palette, grid: GridData { content }, font, objects, portals, scripts: board.scripts.clone(), } } } /// Loads a map file from disk and returns a ready-to-use [`Board`]. /// /// Reads the file at `path`, deserializes it as TOML into a [`MapFile`], /// then converts it via [`TryFrom`]. Propagates I/O errors, TOML /// parse errors, and grid dimension mismatches through the `Box` return. pub fn load(path: &str) -> Result> { let content = std::fs::read_to_string(path)?; let map_file: MapFile = toml::from_str(&content)?; Board::try_from(map_file).map_err(|e| e.into()) } /// Serializes a [`Board`] to a `.toml` map file at `path`. /// /// Converts the board to a [`MapFile`] via [`From<&Board>`], then serializes /// it with `toml::to_string_pretty`. Propagates I/O and serialization errors. pub fn save(board: &Board, path: &Path) -> Result<(), Box> { let map_file = MapFile::from(board); let toml_str = toml::to_string_pretty(&map_file)?; std::fs::write(path, toml_str)?; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::game::Archetype; /// Parses `toml` and converts it to a [`Board`], panicking on any hard error. /// (Object/placement validation failures are non-fatal `eprintln`s, so the /// board still loads — these tests assert on the resulting objects/cells.) fn load_board(toml: &str) -> Board { let mf: MapFile = toml::from_str(toml).expect("parse toml"); 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 = [0, 0] [palette] "." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} "#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }} [grid] content = """ {grid} """ {objects} "## ) } /// 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}" ) } #[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", ""))); assert_eq!(board.objects.len(), 1); assert_eq!((board.objects[0].x, board.objects[0].y), (0, 0)); // The cell under the object is canonical Empty floor. 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()); } #[test] fn palette_char_appearing_twice_drops_object() { let board = load_board(&map_3x1("G.G", &obj_palette("G", ""))); assert!(board.objects.is_empty()); } #[test] fn palette_char_reused_by_two_objects_drops_both() { let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", "")); let board = load_board(&map_3x1("G..", &two)); assert!(board.objects.is_empty()); } #[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()); // 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); } #[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}"))); assert_eq!(board.objects.len(), 1); } /// 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"); // Use r##"..."## so the "# in color strings does not close the raw string. 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} """ "## ) } #[test] fn grid_wrong_row_count_returns_error() { // height = 3 but only 2 rows in the grid let toml = minimal_toml(3, 3, &["...", "..."]); let mf: MapFile = toml::from_str(&toml).unwrap(); let result = Board::try_from(mf); assert!(result.is_err()); // Use .err().unwrap() instead of .unwrap_err() to avoid requiring Board: Debug. 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}" ); } #[test] fn grid_wrong_row_width_returns_error() { // width = 4 but second row is only 3 characters let toml = minimal_toml(4, 2, &["....", "..."]); 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}" ); } #[test] fn object_archetype_in_palette_produces_error_block() { // "object" is no longer a valid palette archetype; it should produce ErrorBlock. let toml = r##" [map] name = "Test" width = 1 height = 1 player_start = [0, 0] [palette] "O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" } [grid] content = """ O """ "##; let mf: MapFile = toml::from_str(toml).unwrap(); let board = Board::try_from(mf).unwrap(); let (_, arch) = board.get(0, 0); assert_eq!(*arch, crate::game::Archetype::ErrorBlock); } #[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(); assert_eq!(board.objects.len(), 1); let obj = &board.objects[0]; 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 } ); // Save back to TOML and reload; glyph must survive. let map_file = MapFile::from(&board); let toml_out = toml::to_string_pretty(&map_file).unwrap(); let mf2: MapFile = toml::from_str(&toml_out).unwrap(); let board2 = Board::try_from(mf2).unwrap(); let obj2 = &board2.objects[0]; assert_eq!(obj2.glyph.tile, obj.glyph.tile); assert_eq!(obj2.glyph.fg, obj.glyph.fg); assert_eq!(obj2.glyph.bg, obj.glyph.bg); } }