use crate::floor::{build_floor, FloorSpec}; use crate::board::Board; use crate::log::LogLine; use color::Rgba8; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; use std::convert::TryFrom; use std::path::Path; use crate::archetype::Archetype; use crate::font::FontSpec; use crate::glyph::Glyph; use crate::object_def::ObjectDef; use crate::utils::{ObjectId, Player, PortalDef}; /// 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, /// Optional `[floor]` declaration: the visual floor layer (a generator name, /// a single glyph, or a grid with its own palette). See [`FloorSpec`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub floor: 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, /// Where the player starts: either explicit `[x, y]` coordinates or a single /// grid character to locate (e.g. `"@"`). See [`PlayerStart`]. pub player_start: PlayerStart, /// 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, Clone, Copy)] #[serde(untagged)] pub 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, } } } /// Where the player starts in a map file: explicit coordinates or a single grid /// character to locate. /// /// Untagged like [`TileIndex`], so `player_start = [30, 12]` parses as /// [`PlayerStart::Coord`] and `player_start = "@"` as [`PlayerStart::Char`] /// (`Coord` is listed first, so a TOML array matches it and a string falls /// through to `Char`). #[derive(Deserialize, Serialize)] #[serde(untagged)] pub enum PlayerStart { /// Explicit `[x, y]` coordinates (0-indexed, origin top-left). Coord([i32; 2]), /// A single grid character marking the player's cell (its cell loads `Empty`). Char(char), } /// 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. pub(crate) 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). pub(crate) 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; // Nonfatal problems collected as we load; surfaced on the Board so callers // can read `Board::is_valid`. Only grid-dimension mismatch (below) is fatal. let mut load_errors: Vec = Vec::new(); // If the player is placed by a grid char, this is that char (e.g. '@'). let player_char = match mf.map.player_start { PlayerStart::Char(c) => Some(c), PlayerStart::Coord(_) => None, }; // 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) => { load_errors.push(LogLine::error(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() { load_errors.push(LogLine::error(format!( "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 { load_errors.push(LogLine::error(format!( "object {i}: palette must be a single character (got {p:?}); skipping object" ))); skip[i] = true; continue; }; // The player's char (e.g. '@') is reserved. if Some(ch) == player_char { load_errors.push(LogLine::error(format!( "object {i}: palette char '{ch}' is reserved for the player; skipping object" ))); skip[i] = true; continue; } // It can't reuse a terrain palette key. if palette.contains_key(&ch) { load_errors.push(LogLine::error(format!( "object {i}: palette char '{ch}' is already a [palette] key; skipping object" ))); skip[i] = true; continue; } // It must be unique across objects — keep the first claimant, drop later ones. use std::collections::hash_map::Entry; match palette_char_owner.entry(ch) { Entry::Occupied(_) => { load_errors.push(LogLine::error(format!( "object {i}: palette char '{ch}' is already used by another object; skipping object" ))); skip[i] = 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(); // Where the player's char was first seen, and how many times (for recovery). let mut player_grid_pos: Option<(usize, usize)> = None; let mut player_grid_count: usize = 0; 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 Some(ch) == player_char { // Player placeholder: floor underneath; keep the first sighting. cells.push(canonical_empty); player_grid_pos.get_or_insert((x, y)); player_grid_count += 1; } else if palette_char_owner.contains_key(&ch) { cells.push(canonical_empty); obj_palette_pos.entry(ch).or_insert((x, y)); *obj_palette_count.entry(ch).or_insert(0) += 1; } else { load_errors.push(LogLine::error(format!( "unknown grid character '{ch}' at ({x}, {y}); using error block" ))); cells.push((Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)); } } } // Resolve each surviving object palette char: missing → drop; multiple → // keep the object at the first occurrence (already recorded) and report. for (&ch, &owner) in &palette_char_owner { if skip[owner] { continue; } match obj_palette_count.get(&ch).copied().unwrap_or(0) { 0 => { load_errors.push(LogLine::error(format!( "object palette char '{ch}' not found in the grid; skipping object" ))); skip[owner] = true; } 1 => {} n => load_errors.push(LogLine::error(format!( "object palette char '{ch}' appears {n} times in the grid; using the first" ))), } } let font = mf.font.map(|f| FontSpec { path: f.path, tile_w: f.tile_w, tile_h: f.tile_h, }); // Build the cached floor layer from the (optional) `[floor]` declaration. // Best-effort: any problem is recorded on `load_errors`, not fatal. The // raw spec is kept on the board so a save can round-trip it. let floor_spec = mf.floor; let floor = build_floor(&floor_spec, w, h, &mut load_errors); // 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(); // Resolve the player's cell (nonfatal; fall back to (0, 0) on any problem), // then let the player *win* its cell: clear solid terrain under it and claim // the cell so a solid object placed here is dropped by the loop below. let (px, py) = match mf.map.player_start { PlayerStart::Coord([x, y]) if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h => { (x as usize, y as usize) } PlayerStart::Coord([x, y]) => { load_errors.push(LogLine::error(format!( "player_start ({x}, {y}) is out of bounds; placing player at (0, 0)" ))); (0, 0) } PlayerStart::Char(c) => match (player_grid_pos, player_grid_count) { (Some(pos), 1) => pos, (Some(pos), n) => { load_errors.push(LogLine::error(format!( "player char '{c}' appears {n} times in the grid; using the first" ))); pos } _ => { load_errors.push(LogLine::error(format!( "player char '{c}' not found in the grid; placing player at (0, 0)" ))); (0, 0) } }, }; let pidx = py * w + px; if solid_occupied[pidx] { cells[pidx] = canonical_empty; } solid_occupied[pidx] = true; // Objects get sequential ids in load order (so BTreeMap iteration order == // load order, preserving the documented "lowest id wins a collision" rule). let mut objects: BTreeMap = BTreeMap::new(); let mut next_object_id: ObjectId = 1; 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)) => { load_errors.push(LogLine::error(format!( "object {i}: x/y ({x}, {y}) out of bounds; skipping object" ))); continue; } _ => { load_errors.push(LogLine::error(format!( "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] { // The player silently wins its own cell (by design); any other // solid collision is a reportable mistake. if idx != pidx { load_errors.push(LogLine::error(format!( "object {i}: solid object at ({x}, {y}) conflicts with an existing solid; skipping object" ))); } continue; } solid_occupied[idx] = true; } objects.insert(next_object_id, obj); next_object_id += 1; } Ok(Board { name: mf.map.name, width: w, height: h, cells, floor, floor_spec, player: Player { x: px as i32, y: py as i32, }, objects, next_object_id, portals: mf.portals, font, zoom: mf.map.zoom, scripts: mf.scripts, board_script_name: mf.map.board_script_name, load_errors, }) } } /// 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 .values() .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: PlayerStart::Coord([board.player.x, board.player.y]), board_script_name: board.board_script_name.clone(), zoom: board.zoom, }, palette, grid: GridData { content }, font, // Round-trip the original floor declaration verbatim (generators // re-randomize on the next load; literal glyph grids are exact). floor: board.floor_spec.clone(), 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::archetype::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 = [2, 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[&1].x, board.objects[&1].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()); assert!(!board.is_valid()); // a dropped object is a recoverable error } #[test] fn palette_char_appearing_twice_uses_first() { // `G` appears at (0,0) and (2,0): the object lands at the first, and the // map is flagged invalid (an extra appearance was reported). let board = load_board(&map_3x1("G.G", &obj_palette("G", ""))); assert_eq!(board.objects.len(), 1); assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); assert!(!board.is_valid()); } #[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()); // 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); assert!(!board.is_valid()); } /// 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} "## ) } #[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()); } #[test] fn player_char_places_on_empty_floor() { let b = load_board(&player_map(3, "\"@\"", ".@.", "")); assert_eq!((b.player.x, b.player.y), (1, 0)); assert_eq!(b.get(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]", "#..", "")); assert_eq!((b.player.x, b.player.y), (0, 0)); assert_eq!(b.get(0, 0).1, Archetype::Empty); 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)); 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, "\"@\"", "@.@", "")); 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, "\"@\"", "...", "")); 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 player is itself solid). The fallback is still reported. let b = load_board(&player_map(3, "\"@\"", "#..", "")); assert_eq!((b.player.x, b.player.y), (0, 0)); assert_eq!(b.get(0, 0).1, Archetype::Empty); // wall cleared under the player assert!(!b.is_valid()); } /// 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. 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(); let (_, arch) = board.get(0, 0); assert_eq!(*arch, 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[&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 } ); // 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[&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); } #[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()); 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). 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()); assert_eq!(board2.glyph_at(1, 0), fixed); } }