//! Per-board map-file load/save and the small serde types that orchestrate it. //! //! A board in a `.toml` world file is a `[map]` header plus an ordered array of //! `[[layers]]` (see [`crate::layer`]). This module owns the [`MapFile`]/ //! [`MapHeader`] serde shells and the conversions to and from a runtime //! [`Board`]; the per-layer grid/palette work lives in [`crate::layer`]. //! //! Loading is **best-effort/nonfatal**: only a layer grid-dimension mismatch is a //! hard error. Every other problem (unknown archetype/char → `ErrorBlock`, missing //! or duplicate player cell, two solids stacked on a cell, duplicate object/portal //! names) is recovered and recorded on [`Board::load_errors`]. use color::Rgba8; use serde::{Deserialize, Serialize}; use std::collections::hash_map::Entry; use std::collections::{BTreeMap, HashMap, HashSet}; use std::convert::TryFrom; use std::path::Path; use crate::api::queue::ObjQueue; use crate::archetype::Archetype; use crate::board::{Board, Decoration}; use crate::builtin_scripts::archetype_from_builtin_tag; use crate::floor::{Floor, FloorGenerator}; use crate::glyph::Glyph; use crate::layer::{GridData, PaletteEntry, Placement, build_grid}; use crate::log::LogLine; use crate::object_def::ObjectDef; use crate::utils::{ObjectId, PlayerPos, PortalDef}; /// The serde shell for one board in a `.toml` file: a header, the single grid, and /// the off-grid trigger/decoration lists. /// /// On load this is converted into a [`Board`] via [`TryFrom`] and discarded; on /// save a [`Board`] is converted back via [`From<&Board>`]. See `maps/start.toml` /// for a complete example of the format. #[derive(Deserialize, Serialize)] pub struct MapFile { /// The `[map]` header: name, dimensions, floor, optional board script. pub map: MapHeader, /// The single `[grid]`: palette + char map for all solids and most non-solids. #[serde(default)] pub(crate) grid: GridData, /// Invisible, non-solid, script-only objects (`[[triggers]]`). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) triggers: Vec, /// Non-solid `(glyph, archetype)` cells drawn only where the grid is empty /// (`[[decorations]]`). Normally absent; used by save files. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) decorations: Vec, } /// The `[map]` header section of a board. #[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 grid row length. pub width: usize, /// Height of the board in cells. Must match the grid row count. pub height: usize, /// The board's optional cosmetic floor. Absent ⇒ blank; see [`FloorSpec`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub floor: Option, /// 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, /// When `true`, this is a "dark" board: front-ends reveal only cells within /// the player's field of view. Absent ⇒ `false` (fully lit); omitted from /// the saved TOML when `false`. See [`Board::dark`](crate::board::Board::dark). #[serde(default, skip_serializing_if = "is_false")] pub dark: bool, } /// serde `skip_serializing_if` predicate: omit a `bool` field when it is `false` /// (so the common non-dark board doesn't emit a `dark = false` line). fn is_false(b: &bool) -> bool { !*b } /// Serde form of the board floor attribute (`floor = { … }` in `[map]`). /// /// Resolves (in [`FloorSpec::resolve`]) to a [`Floor`]: a `generator` name gives a /// biome, otherwise any of `tile`/`fg`/`bg` gives a single fixed glyph, and an /// empty spec is blank. #[derive(Deserialize, Serialize, Clone)] pub struct FloorSpec { /// Procedural biome name (`"grass"`/`"dirt"`/`"stone"`). #[serde(default, skip_serializing_if = "Option::is_none")] pub generator: Option, /// Fixed-glyph tile index (int or single-char string). #[serde(default, skip_serializing_if = "Option::is_none")] pub tile: Option, /// Fixed-glyph foreground `"#RRGGBB"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub fg: Option, /// Fixed-glyph background `"#RRGGBB"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub bg: Option, } impl FloorSpec { /// Resolves this spec to a [`Floor`] for a `width × height` board, recording a /// nonfatal error (and falling back to [`Floor::Blank`]) for an unknown generator. fn resolve(&self, width: usize, height: usize, errors: &mut Vec) -> Floor { if let Some(name) = &self.generator { return match FloorGenerator::from_name(name) { Some(g) => Floor::biome(g, width, height), None => { errors.push(LogLine::error(format!( "floor names unknown generator '{name}'; using blank floor" ))); Floor::Blank } }; } // A fixed glyph if any visual field is given, else a blank floor. if self.tile.is_some() || self.fg.is_some() || self.bg.is_some() { Floor::Fixed(Glyph { tile: self.tile.map(TileIndex::into_u32).unwrap_or(32), fg: self.fg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }), bg: self.bg.as_deref().map(parse_color).unwrap_or(Rgba8 { r: 0, g: 0, b: 0, a: 255 }), }) } else { Floor::Blank } } } /// Serde form of one `[[triggers]]` entry: an invisible, non-solid, script-only /// object at `(x, y)`. Triggers are folded into [`Board::objects`] at load; they /// are re-emitted here on save (recognised as scripted, non-solid, glyphless). #[derive(Deserialize, Serialize, Clone)] pub(crate) struct TriggerSpec { /// Column (0-indexed). pub x: usize, /// Row (0-indexed). pub y: usize, /// Name of the Rhai script (in `[scripts]`) this trigger runs. pub script_name: String, /// Optional board-unique name. #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, /// Optional open-ended labels. #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option>, } /// Serde form of one `[[decorations]]` entry: a non-solid `(glyph, archetype)` at /// `(x, y)`, drawn only where the grid cell is empty. A solid archetype is rejected. #[derive(Deserialize, Serialize, Clone)] pub(crate) struct DecorationSpec { /// Column (0-indexed). pub x: usize, /// Row (0-indexed). pub y: usize, /// Archetype name (or `"empty"`); must be non-solid. pub kind: String, /// Tile index (int or single-char string). #[serde(default, skip_serializing_if = "Option::is_none")] pub tile: Option, /// Foreground `"#RRGGBB"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub fg: Option, /// Background `"#RRGGBB"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub bg: Option, } /// A tile index in a palette entry: either a plain integer or a character literal. /// /// Accepting both forms lets map files write `tile = 35` or `tile = "#"` (the char /// is converted to its Unicode scalar) interchangeably. #[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 { /// Returns the tile index as a `u32`, converting a char to its scalar value. pub(crate) fn into_u32(self) -> u32 { match self { TileIndex::Num(n) => n, TileIndex::Chr(c) => c as u32, } } } /// 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`]. /// /// Builds the single grid (collecting object/portal/player placements), resolves /// the floor, then runs the cross-cell validations that span the whole board: /// - the player must appear exactly once (missing → `(0, 0)`; multiple → the first), and wins its cell; /// - at most one solid may occupy a cell (a conflicting solid object is dropped); /// - object names must be board-unique (a duplicate is cleared) and portal names unique (a duplicate is dropped). /// /// Finally the `[[triggers]]` load as non-solid glyphless objects and the /// `[[decorations]]` as off-grid non-solid cells. /// /// Returns `Err` only when the grid dimensions disagree with the header. impl TryFrom for Board { type Error = String; fn try_from(mf: MapFile) -> Result { let w = mf.map.width; let h = mf.map.height; let mut load_errors: Vec = Vec::new(); // Build the single grid, collecting non-terrain placements. let (mut grid, placements) = build_grid(&mf.grid, w, h, &mut load_errors)?; let mut object_specs: Vec<(crate::layer::ObjectTemplate, usize, usize)> = Vec::new(); let mut portal_specs: Vec<(crate::layer::PortalTemplate, usize, usize)> = Vec::new(); let mut player_positions: Vec<(usize, usize)> = Vec::new(); for p in placements { match p { Placement::Object(t, x, y) => object_specs.push((t, x, y)), Placement::Portal(t, x, y) => portal_specs.push((t, x, y)), Placement::Player(x, y) => player_positions.push((x, y)), } } // Resolve the cosmetic floor attribute (blank / fixed glyph / biome). let floor = mf .map .floor .as_ref() .map(|f| f.resolve(w, h, &mut load_errors)) .unwrap_or(Floor::Blank); // The player must be placed exactly once. let (px, py) = match player_positions.len() { 1 => player_positions[0], 0 => { load_errors.push(LogLine::error( "no player cell (kind = \"player\") found; placing player at (0, 0)", )); (0, 0) } n => { load_errors.push(LogLine::error(format!( "player cell appears {n} times; using the first" ))); player_positions[0] } }; let pidx = py * w + px; // Track which cells hold a solid (the grid's own solids seed the map). let mut solid_occupied = vec![false; w * h]; for (idx, cell) in grid.iter().enumerate() { if cell.1.behavior().solid { solid_occupied[idx] = true; } } // The player wins its cell: clear any solid terrain under it and claim the // cell so a solid object placed here is dropped below. if grid[pidx].1.behavior().solid { grid[pidx] = (Glyph::transparent(), Archetype::Empty); } solid_occupied[pidx] = true; // Spawn objects in reading order, so ids are deterministic and "lowest id // wins a collision" / "first claimant keeps the name" hold. let mut objects: BTreeMap = BTreeMap::new(); let mut next_object_id: ObjectId = 1; let mut seen_names: HashMap = HashMap::new(); for (t, x, y) in object_specs { let idx = y * w + x; // A solid object may not share a cell with another solid. if t.solid && solid_occupied[idx] { // The player silently wins its cell; any other conflict is reported. if idx != pidx { load_errors.push(LogLine::error(format!( "solid object at ({x}, {y}) conflicts with an existing solid; skipping object" ))); } continue; } let id = next_object_id; // Name uniqueness: first claimant keeps it; later duplicates are cleared. let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors)); if t.solid { solid_occupied[idx] = true; } objects.insert( id, ObjectDef { id, x, y, glyph: t.glyph, solid: t.solid, opaque: t.opaque, pushable: t.pushable, light: t.light, // Hand-placed objects are never grab targets; only expanded // grab archetypes (e.g. gems) set this (see expand_builtin_archetypes). grab: false, script_name: t.script_name, // Script-backed archetypes are expanded after the board is built // (see `expand_builtin_archetypes` below), not via templates. builtin_script: None, tags: t.tags.into_iter().collect(), queue: ObjQueue::new(), name, }, ); next_object_id += 1; } // Triggers: invisible, non-solid, script-only objects. They join the same // `objects` map (so ids/name-uniqueness/script dispatch all apply), after the // hand-placed grid objects. for t in mf.triggers { if !t.x.lt(&w) || !t.y.lt(&h) { load_errors.push(LogLine::error(format!( "trigger at ({}, {}) is out of bounds; skipping", t.x, t.y ))); continue; } let id = next_object_id; let name = t.name.and_then(|n| claim_name(n, id, &mut seen_names, &mut load_errors)); objects.insert( id, ObjectDef { id, x: t.x, y: t.y, glyph: Glyph::transparent(), solid: false, opaque: false, pushable: false, light: 0, grab: false, script_name: Some(t.script_name), builtin_script: None, tags: t.tags.unwrap_or_default().into_iter().collect(), queue: ObjQueue::new(), name, }, ); next_object_id += 1; } // Build the portal list, dropping duplicate names (first claimant wins). let mut seen_portal_names: HashSet = HashSet::new(); let mut portals: Vec = Vec::new(); for (t, x, y) in portal_specs { if !seen_portal_names.insert(t.name.clone()) { load_errors.push(LogLine::error(format!( "portal name {:?} already used by another portal; skipping portal", t.name ))); continue; } portals.push(PortalDef { name: t.name, x, y, target_map: t.target_map, target_entry: t.target_entry, }); } // Decorations: non-solid off-grid cells (a solid archetype is rejected). let mut decorations: Vec = Vec::new(); for d in mf.decorations { match resolve_decoration(&d) { Ok(dec) => decorations.push(dec), Err(msg) => load_errors.push(LogLine::error(msg)), } } let mut board = Board { name: mf.map.name, width: w, height: h, grid, floor, decorations, player: PlayerPos { x: px as i64, y: py as i64, }, objects, next_object_id, portals, board_script_name: mf.map.board_script_name, dark: mf.map.dark, load_errors, registry: HashMap::new(), }; // Turn script-backed archetype cells (pushers/spinners) into their scripted // objects. Runs after the cross-cell validation above, so board invariants // hold; the same call also fixes editor-placed machines before a playtest. board.expand_builtin_archetypes(); Ok(board) } } /// Claims `name` for object `id` in `seen_names`, returning `Some(name)` for the /// first claimant and `None` (with a logged error) for any later duplicate. fn claim_name( n: String, id: ObjectId, seen_names: &mut HashMap, errors: &mut Vec, ) -> Option { match seen_names.entry(n.clone()) { Entry::Vacant(v) => { v.insert(id); Some(n) } Entry::Occupied(o) => { errors.push(LogLine::error(format!( "object name {n:?} already used by object {}; clearing name", o.get() ))); None } } } /// Resolves a [`DecorationSpec`] into a [`Decoration`], erroring if its archetype is /// unknown or solid (decorations must be non-solid). fn resolve_decoration(d: &DecorationSpec) -> Result { let arch = if d.kind == "empty" { Archetype::Empty } else { Archetype::try_from(d.kind.as_str()) .map_err(|msg| format!("decoration at ({}, {}): {msg}; skipping", d.x, d.y))? }; if arch.behavior().solid { return Err(format!( "decoration at ({}, {}) has solid archetype {:?}; skipping", d.x, d.y, d.kind )); } let default = arch.default_glyph(); Ok(Decoration { x: d.x, y: d.y, glyph: Glyph { tile: d.tile.map(TileIndex::into_u32).unwrap_or(default.tile), fg: d.fg.as_deref().map(parse_color).unwrap_or(default.fg), bg: d.bg.as_deref().map(parse_color).unwrap_or(default.bg), }, archetype: arch, }) } /// Pool of palette characters for save: printable ASCII (plus a leading space for /// the common transparent-empty cell), excluding `"` and `\` which would need /// escaping inside a TOML string. fn char_pool() -> Vec { let mut pool = vec![' ']; pool.extend( (33u8..=126u8) .filter(|&b| b != b'"' && b != b'\\') .map(|b| b as char), ); pool } /// Builds the [`PaletteEntry`] for a grid terrain cell `(glyph, arch)`. /// /// A grid `Empty` cell is always transparent now (floors are a board attribute), /// so it maps to `kind = "empty"`; anything else is its archetype keyword. fn cell_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry { if arch == Archetype::Empty { PaletteEntry { kind: "empty".into(), ..Default::default() } } else { PaletteEntry { kind: arch.name().into(), tile: Some(TileIndex::Num(glyph.tile)), fg: Some(color_to_hex(glyph.fg)), bg: Some(color_to_hex(glyph.bg)), ..Default::default() } } } /// Whether an object is a **trigger** — an invisible, non-solid, script-only object /// authored/serialized in `[[triggers]]` rather than the grid palette. fn is_trigger(o: &ObjectDef) -> bool { !o.solid && o.glyph.tile == 0 && o.script_name.is_some() && o.builtin_script.is_none() } /// Serializes `board`'s single grid (terrain + non-trigger objects + portals + /// player) into a [`GridData`]. fn grid_to_data(board: &Board) -> GridData { let (w, h) = (board.width, board.height); let mut pool = char_pool().into_iter(); let mut palette: HashMap = HashMap::new(); let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new(); let mut grid: Vec> = vec![vec![' '; w]; h]; // Terrain cells: dedup each unique (glyph, archetype) to one palette char. for (y, row) in grid.iter_mut().enumerate() { for (x, slot) in row.iter_mut().enumerate() { let (glyph, arch) = *board.get(x, y); *slot = *cell_to_key.entry((glyph, arch)).or_insert_with(|| { let ch = pool.next().expect("ran out of palette characters"); palette.insert(ch.to_string(), cell_entry(glyph, arch)); ch }); } } // Objects overwrite their (transparent) grid cell with an object char. Triggers // are written to `[[triggers]]` instead (see `From<&Board>`), so skip them here. for o in board.objects.values().filter(|o| !is_trigger(o)) { // A built-in archetype object (e.g. a pusher) round-trips back to its // archetype keyword: emit it as a terrain cell (deduped with real terrain), // using the object's current glyph, rather than a `kind = "object"` entry. if let Some(arch) = o.tags.iter().find_map(|t| archetype_from_builtin_tag(t)) { let ch = *cell_to_key.entry((o.glyph, arch)).or_insert_with(|| { let ch = pool.next().expect("ran out of palette characters"); palette.insert(ch.to_string(), cell_entry(o.glyph, arch)); ch }); grid[o.y][o.x] = ch; continue; } let ch = pool.next().expect("ran out of palette characters"); let mut tags: Vec = o.tags.iter().cloned().collect(); tags.sort(); palette.insert( ch.to_string(), PaletteEntry { kind: "object".into(), tile: Some(TileIndex::Num(o.glyph.tile)), fg: Some(color_to_hex(o.glyph.fg)), bg: Some(color_to_hex(o.glyph.bg)), solid: Some(o.solid), opaque: Some(o.opaque), pushable: Some(o.pushable), light: (o.light > 0).then_some(o.light), script_name: o.script_name.clone(), tags: (!tags.is_empty()).then_some(tags), name: o.name.clone(), ..Default::default() }, ); grid[o.y][o.x] = ch; } // Portals. for p in board.portals.iter() { let ch = pool.next().expect("ran out of palette characters"); palette.insert( ch.to_string(), PaletteEntry { kind: "portal".into(), name: Some(p.name.clone()), target_map: Some(p.target_map.clone()), target_entry: Some(p.target_entry.clone()), ..Default::default() }, ); grid[p.y][p.x] = ch; } // The player. { let ch = pool.next().expect("ran out of palette characters"); palette.insert( ch.to_string(), PaletteEntry { kind: "player".into(), ..Default::default() }, ); grid[board.player.y as usize][board.player.x as usize] = ch; } let content = grid .into_iter() .map(|row| row.into_iter().collect::()) .collect::>() .join("\n") + "\n"; // Save always emits an explicit grid; `fill`/`sparse` are load-time conveniences. GridData { content: Some(content), fill: None, sparse: None, palette, } } /// Builds the [`FloorSpec`] for `board`'s floor, or `None` for a blank floor. A /// biome re-emits its generator name (so the procedural floor round-trips); a fixed /// glyph re-emits its tile/fg/bg. fn floor_to_spec(floor: &Floor) -> Option { match floor { Floor::Blank => None, Floor::Fixed(g) => Some(FloorSpec { generator: None, tile: Some(TileIndex::Num(g.tile)), fg: Some(color_to_hex(g.fg)), bg: Some(color_to_hex(g.bg)), }), Floor::Biome { generator, .. } => Some(FloorSpec { generator: Some(generator.name().into()), tile: None, fg: None, bg: None, }), } } /// Converts a runtime [`Board`] back into a serializable [`MapFile`]. /// /// Emits the single `[grid]`, the `[[triggers]]` (invisible script objects) and /// `[[decorations]]` lists, and the `floor` attribute (a biome re-emits its /// generator name, so procedural floors round-trip). impl From<&Board> for MapFile { fn from(board: &Board) -> Self { let grid = grid_to_data(board); // Trigger objects → `[[triggers]]`. let triggers = board .objects .values() .filter(|o| is_trigger(o)) .map(|o| { let mut tags: Vec = o.tags.iter().cloned().collect(); tags.sort(); TriggerSpec { x: o.x, y: o.y, script_name: o.script_name.clone().unwrap_or_default(), name: o.name.clone(), tags: (!tags.is_empty()).then_some(tags), } }) .collect(); // Decorations → `[[decorations]]`. let decorations = board .decorations .iter() .map(|d| DecorationSpec { x: d.x, y: d.y, kind: d.archetype.name().into(), tile: Some(TileIndex::Num(d.glyph.tile)), fg: Some(color_to_hex(d.glyph.fg)), bg: Some(color_to_hex(d.glyph.bg)), }) .collect(); MapFile { map: MapHeader { name: board.name.clone(), width: board.width, height: board.height, floor: floor_to_spec(&board.floor), board_script_name: board.board_script_name.clone(), dark: board.dark, }, grid, triggers, decorations, } } } /// Loads a map file from disk and returns a ready-to-use [`Board`]. /// /// Reads the file at `path`, deserializes it as a single-board [`MapFile`], then /// converts it via [`TryFrom`]. Production code uses [`crate::world::load`] for /// multi-board world files instead. 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`. 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(()) }