use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, Player, PortalDef}; use eframe::egui::Color32; 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 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, } /// 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)] 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 { 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, } /// Parses an `"#RRGGBB"` hex color string into a [`Color32`]. /// Returns black on any parse failure. fn parse_color(hex: &str) -> Color32 { let hex = hex.trim_start_matches('#'); if hex.len() != 6 { return Color32::BLACK; } 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); Color32::from_rgb(r, g, b) } /// Converts a [`Color32`] to an `"#RRGGBB"` hex string. fn color_to_hex(color: Color32) -> 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 )); } } // Pass 2: walk the validated grid and build cells. let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h); for line in &rows { for ch in line.chars() { if let Some(&cell) = palette.get(&ch) { cells.push(cell); } } } let font = mf.font.map(|f| FontSpec { path: f.path, tile_w: f.tile_w, tile_h: f.tile_h, }); 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: mf.objects, portals: mf.portals, font, 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, }); // Reconstruct objects as plain structs (ObjectDef/PortalDef don't implement Clone). let objects = board .objects .iter() .map(|o| ObjectDef { x: o.x, y: o.y, 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(), }, 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::*; /// 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}"); } }