Files
kiln/kiln-core/src/map_file.rs
T

1030 lines
38 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.
use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, Player, PortalDef};
use crate::log::LogLine;
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<MapFile>`] 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<String, PaletteEntry>,
/// 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<FontHeader>,
/// Any `[[objects]]` entries. Optional; defaults to empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) objects: Vec<MapFileObjectEntry>,
/// Any `[[portals]]` entries. Optional; defaults to empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub portals: Vec<PortalDef>,
/// 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<String, String>,
}
/// 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<String>,
/// 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,
}
}
}
/// 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<usize>,
/// Row of this object on the board (0-indexed). See `x`.
#[serde(default, skip_serializing_if = "Option::is_none")]
y: Option<usize>,
/// 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<String>,
/// 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<String>,
}
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<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;
// 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<LogLine> = 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<char, (Glyph, Archetype)> = 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<char, usize> = 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<char, (usize, usize)> = HashMap::new();
let mut obj_palette_count: HashMap<char, usize> = 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,
});
// 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<bool> = 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;
let mut objects: Vec<ObjectDef> = 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)) => {
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.push(obj);
}
Ok(Board {
name: mf.map.name,
width: w,
height: h,
cells,
player: Player {
x: px as i32,
y: py as i32,
},
objects,
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<char> = (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<String, PaletteEntry> = 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<String> = 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<MapFileObjectEntry> = 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: PlayerStart::Coord([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<MapFile>`]. Propagates I/O errors, TOML
/// parse errors, and grid dimension mismatches through the `Box<dyn Error>` return.
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`.
///
/// 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<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(())
}
#[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 = [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[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());
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[0].x, board.objects[0].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, 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);
}
}