Files
kiln/kiln-core/src/map_file.rs
T
2026-07-11 14:47:08 -05:00

716 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<TriggerSpec>,
/// 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<DecorationSpec>,
}
/// 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<FloorSpec>,
/// 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<String>,
/// 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<String>,
/// Fixed-glyph tile index (int or single-char string).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<TileIndex>,
/// Fixed-glyph foreground `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
/// Fixed-glyph background `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
}
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<LogLine>) -> 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<String>,
/// Optional open-ended labels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
/// 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<TileIndex>,
/// Foreground `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
/// Background `"#RRGGBB"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
}
/// 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<MapFile> for Board {
type Error = String;
fn try_from(mf: MapFile) -> Result<Self, Self::Error> {
let w = mf.map.width;
let h = mf.map.height;
let mut load_errors: Vec<LogLine> = 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<ObjectId, ObjectDef> = BTreeMap::new();
let mut next_object_id: ObjectId = 1;
let mut seen_names: HashMap<String, ObjectId> = 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<String> = HashSet::new();
let mut portals: Vec<PortalDef> = 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<Decoration> = 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<String, ObjectId>,
errors: &mut Vec<LogLine>,
) -> Option<String> {
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<Decoration, String> {
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<char> {
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<String, PaletteEntry> = HashMap::new();
let mut cell_to_key: HashMap<(Glyph, Archetype), char> = HashMap::new();
let mut grid: Vec<Vec<char>> = 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<String> = 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::<String>())
.collect::<Vec<_>>()
.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<FloorSpec> {
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<String> = 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<Board, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let map_file = MapFile::from(board);
let toml_str = toml::to_string_pretty(&map_file)?;
std::fs::write(path, toml_str)?;
Ok(())
}