diff --git a/README.md b/README.md index 7bb0cf9..123f21c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ -# tilemapper +# TileMapper -A TTRPG battle map generator using custom tile sets. \ No newline at end of file +A TTRPG battle map generator using custom tile sets. + +## Overview + +WIP + +## Quick start + +WIP diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..42fd568 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[tool.poetry] +name = "tilemapper" +version = "0.1.0" +description = "" +authors = ["evilchili "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +typer = "^0.16.0" +pillow = "^11.3.0" + + +[tool.poetry.group.dev.dependencies] +pytest = "^8.4.1" +pytest-cov = "^6.2.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + + +### SLAM + +[tool.black] +line-length = 120 +target-version = ['py310'] + +[tool.isort] +multi_line_output = 3 +line_length = 120 +include_trailing_comma = true + +[tool.autoflake] +check = false # return error code if changes are needed +in-place = true # make changes to files instead of printing diffs +recursive = true # drill down directories recursively +remove-all-unused-imports = true # remove all unused imports (not just those from the standard library) +ignore-init-module-imports = true # exclude __init__.py when removing unused imports +remove-duplicate-keys = true # remove all duplicate keys in objects +remove-unused-variables = true # remove unused variables + +[tool.pytest.ini_options] +log_cli_level = "DEBUG" +addopts = "--cov=src --cov-report=term-missing" + +### ENDSLAM + + +[tool.poetry.scripts] +mapper = "tilemapper.cli:app" diff --git a/samples/edge_test.txt b/samples/edge_test.txt new file mode 100644 index 0000000..1ac2800 --- /dev/null +++ b/samples/edge_test.txt @@ -0,0 +1,10 @@ + ,,,,,,,, + m,__,,,_, + m____,,,, + m.__,,,,, +mm...,,,, +m,___.,,,, + m,,,,,,, + + + diff --git a/samples/edge_test_small.txt b/samples/edge_test_small.txt new file mode 100644 index 0000000..c6f7446 --- /dev/null +++ b/samples/edge_test_small.txt @@ -0,0 +1,5 @@ +,,,,,,, +,,___,, +,,___,, +,,___,, +,,,,,,, diff --git a/samples/five_room_dungeon.txt b/samples/five_room_dungeon.txt new file mode 100644 index 0000000..c8dfa55 --- /dev/null +++ b/samples/five_room_dungeon.txt @@ -0,0 +1,20 @@ +..... 1 +..... ..... 2 +..........D.... +..... ..D.. + . + . + S......... + . . + . . + ..... 3 .L.... 4 + ..... ...... + ..... ...... + ....v. + + .^......... + ........... + ........... + ........... + ........... 5 + diff --git a/samples/outdoor_test.txt b/samples/outdoor_test.txt new file mode 100644 index 0000000..4722648 --- /dev/null +++ b/samples/outdoor_test.txt @@ -0,0 +1,10 @@ + ,,,,TTTTT + ,,,.....TTT + ,,,.....TTTTT + ,,.......TTT + ,,........TT +MM._________..M +MM.________..MM +MM._____o_____..M +M__o___________.M + diff --git a/samples/test.txt b/samples/test.txt new file mode 100644 index 0000000..929b4ea --- /dev/null +++ b/samples/test.txt @@ -0,0 +1,7 @@ +,,,,,,,,,,,,,, +,,,........,,, +,,,.______.,,, +,,,._o____.,,, +,,,.______.,,, +,,,........,,, +,,,,,,,,,,,,,, diff --git a/src/tilemapper/README.md b/src/tilemapper/README.md new file mode 100644 index 0000000..e69de29 diff --git a/src/tilemapper/__init__.py b/src/tilemapper/__init__.py new file mode 100644 index 0000000..2a970bc --- /dev/null +++ b/src/tilemapper/__init__.py @@ -0,0 +1 @@ +from . import battlemap, tileset diff --git a/src/tilemapper/battlemap.py b/src/tilemapper/battlemap.py new file mode 100644 index 0000000..b954753 --- /dev/null +++ b/src/tilemapper/battlemap.py @@ -0,0 +1,94 @@ +# import logging +from dataclasses import dataclass +from functools import cached_property +from io import StringIO +from pathlib import Path +from textwrap import indent +from typing import Union + +from PIL import Image + +from tilemapper.grid import Grid, Position +from tilemapper.tileset import TileSet + + +class UnsupportedTileException(Exception): + pass + + +@dataclass +class BattleMap: + name: str = "" + source: Union[StringIO, Path] = None + source_data: str = "" + tileset: TileSet = None + width: int = 0 + height: int = 0 + + def load(self): + try: + data = self.source.read_bytes().decode() + except AttributeError: + data = self.source.read() + data = data.strip("\n") + if not self.validate_source_data(data): + return + self.source_data = data + if hasattr(self, "grid"): + del self.grid + return self.grid + + def validate_source_data(self, data: str): + for char in set(list(data)): + if char == "\n": + continue + if char not in self.tileset.character_map: + raise UnsupportedTileException(f"The current tileset does not support the '{char}' character.") + return True + + def render(self): + map_image = Image.new("RGB", (self.tileset.tile_size * self.width, self.tileset.tile_size * self.height)) + empty_space = Position(y=-1, x=-1, value=self.tileset.empty_space) + for y in range(0, self.height): + for x in range(0, self.width): + pos = self.grid.at(y, x, empty_space) + map_image.paste( + self.tileset.render_tile(pos, [a or pos for a in self.grid.adjacent(pos)]), + (self.tileset.tile_size * x, self.tileset.tile_size * y), + ) + return map_image + + @cached_property + def grid(self): + matrix = [] + for line in self.source_data.splitlines(): + matrix.append([self.tileset.get(char) for char in line]) + self.width = max([len(line) for line in matrix]) + self.height = len(matrix) + return Grid(data=matrix) + + @property + def title(self): + return f"BattleMap: {self.name} ({self.width} x {self.height}, {self.width * self.height * 5}sq.ft.)" + + @property + def legend(self): + output = "" + for char in sorted(set(list(self.source_data)), key=str.lower): + if char in self.tileset.character_map: + output += f"{char} - {self.tileset.character_map[char]}\n" + return output + + def __str__(self): + output = "" + for y in range(0, self.height): + for x in range(0, self.width): + try: + output += str(self.grid.at(y, x).value) + except AttributeError: + continue + output += "\n" + return output.rstrip("\n") + + def __repr__(self): + return f"\n{self.title}\n\n{indent(str(self), ' ')}\n\nLegend:\n{indent(self.legend, ' ')}" diff --git a/src/tilemapper/cli.py b/src/tilemapper/cli.py new file mode 100644 index 0000000..0b306bb --- /dev/null +++ b/src/tilemapper/cli.py @@ -0,0 +1,74 @@ +import logging +import os +import sys +from pathlib import Path + +import typer +from rich.console import Console +from rich.logging import RichHandler + +from tilemapper import battlemap +from tilemapper import tileset as _tileset + +app = typer.Typer() +app_state = {} + + +@app.callback(invoke_without_command=True) +def main( + ctx: typer.Context, + config_dir: Path = Path(__file__).parent.parent / "tilesets", + verbose: bool = typer.Option(default=False, help="If True, increase verbosity of status messages."), +): + app_state["tileset_manager"] = _tileset.TileSetManager(config_dir) + app_state["config_dir"] = config_dir + app_state["verbose"] = verbose + debug = os.getenv("DEBUG", None) + logging.basicConfig( + format="%(name)s %(message)s", + level=logging.DEBUG if debug else logging.INFO, + handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])], + ) + + +@app.command() +def list(): + manager = app_state["tileset_manager"] + print("\n".join(manager.available)) + + +@app.command() +def inspect(source: Path): + manager = app_state["tileset_manager"] + bmap = battlemap.BattleMap(name=source.name, source=source, tileset=manager.console_map) + bmap.load() + console = Console() + console.print(repr(bmap), highlight=False) + + +@app.command() +def render(source: Path, outfile: Path, tileset: str = typer.Option(help="The tile set to use.")): + manager = app_state["tileset_manager"] + if tileset not in manager.available: + raise RuntimeError(f"Could not locate the tile set {tileset} in {manager.config_dir}.") + + try: + bmap = battlemap.BattleMap(name=source.name, source=source, tileset=manager.load(tileset)) + except _tileset.MissingTileException as e: + logging.error(e) + sys.exit(1) + + try: + bmap.load() + except battlemap.UnsupportedTileException as e: + logging.error(e) + sys.exit(1) + + image = bmap.render() + # image = image.resize((int(0.5 * image.size[0]), int(0.5 * image.size[1]))) + image.save(outfile) + print(f"Wrote {outfile.stat().st_size} bytes to {outfile}") + + +if __name__ == "__main__": + app() diff --git a/src/tilemapper/generator.py b/src/tilemapper/generator.py new file mode 100644 index 0000000..af460c5 --- /dev/null +++ b/src/tilemapper/generator.py @@ -0,0 +1,2 @@ +def random(): + pass diff --git a/src/tilemapper/grid.py b/src/tilemapper/grid.py new file mode 100644 index 0000000..7fa8598 --- /dev/null +++ b/src/tilemapper/grid.py @@ -0,0 +1,55 @@ +from collections import namedtuple + +Position = namedtuple("GridPosition", ["y", "x", "value"]) + + +class Grid: + def __init__(self, data): + self.data = [] + for y in range(len(data)): + row = [] + for x in range(len(data[y])): + row.append(Position(y=y, x=x, value=data[y][x])) + self.data.append(row) + + def at(self, y, x, default=None): + try: + return self.data[y][x] + except IndexError: + return default + + def north(self, position: Position): + return self.at(position.y - 1, position.x) + + def east(self, position: Position): + return self.at(position.y, position.x + 1) + + def south(self, position: Position): + return self.at(position.y + 1, position.x) + + def west(self, position: Position): + return self.at(position.y, position.x - 1) + + def northwest(self, position: Position): + return self.at(position.y - 1, position.x - 1) + + def northeast(self, position: Position): + return self.at(position.y - 1, position.x + 1) + + def southwest(self, position: Position): + return self.at(position.y + 1, position.x - 1) + + def southeast(self, position: Position): + return self.at(position.y + 1, position.x + 1) + + def adjacent(self, position: Position): + return ( + self.northwest(position), + self.north(position), + self.northeast(position), + self.east(position), + self.southeast(position), + self.south(position), + self.southwest(position), + self.west(position), + ) diff --git a/src/tilemapper/tileset.py b/src/tilemapper/tileset.py new file mode 100644 index 0000000..a19c467 --- /dev/null +++ b/src/tilemapper/tileset.py @@ -0,0 +1,284 @@ +import random +import tomllib +from dataclasses import dataclass, field +from functools import cached_property +from pathlib import Path +from typing import Dict, List + +from PIL import Image, ImageDraw + +from tilemapper.grid import Position + +DEFAULT_TILE_SIZE_IN_PIXELS = 128 + +COLOR_MAP = { + ".": "white on grey58", + "d": "bold cyan on grey58", + "D": "bold green on grey58", + "L": "bold dark_red on grey58", + "S": "bold black on grey58", + "v": "bold steel_blue on grey3", + "^": "bold steel_blue on grey3", + "0": "white on dark_red", + "1": "white on dark_red", + "2": "white on dark_red", + "3": "white on dark_red", + "4": "white on dark_red", + "5": "white on dark_red", + "6": "white on dark_red", + "7": "white on dark_red", + "8": "white on dark_red", + "9": "white on dark_red", +} + + +class MissingTileException(Exception): + pass + + +@dataclass(kw_only=True) +class Tile: + name: str + char: str + + def render(self): + return str(self) + + @cached_property + def terrain_name(self): + return self.name.split("_", 1)[0] + + def __str__(self): + return self.char + + +@dataclass(kw_only=True) +class TileSet: + name: str + desc: str = "" + tiles: Dict[str, Tile] = field(default_factory=dict) + character_map: Dict[str, str] = field( + default_factory=lambda: { + " ": "empty", + ".": "ground", + "d": "door, open", + "D": "door, closed", + "L": "door, locked", + "S": "door, secret", + "v": "stairs, down", + "^": "stairs, up", + "0": "location 0", + "1": "location 1", + "2": "location 2", + "3": "location 3", + "4": "location 4", + "5": "location 5", + "6": "location 6", + "7": "location 7", + "8": "location 8", + "9": "location 9", + } + ) + + def add(self, *tiles: Tile): + for tile in tiles: + self.tiles[tile.name] = tile + + def get(self, char: str): + t = self.character_map[char] + try: + return self.tiles[f"{t}"] + except KeyError: + raise MissingTileException(f'"{self}" does not contain a "{t}" tile.') + + @property + def empty_space(self): + return self.tiles[self.character_map[" "]] + + def __str__(self): + return f"[Tileset] {self.name}: {self.desc}" + + +@dataclass +class ColorizedTile(Tile): + color: str + + def __str__(self): + return f"[{self.color}]{self.char}[/{self.color}]" + + +class ColorizedTileSet(TileSet): + tiles: Dict[str, ColorizedTile] = {} + + +@dataclass +class ImageTile(Tile): + paths: List[Path] + buffer: Image = None + + @property + def image(self): + if self.buffer: + return self.buffer + return Image.open(random.choice(self.paths)) + + @property + def width(self): + return self.image.size[0] + + @property + def height(self): + return self.image.size[1] + + def render( + self, + size: int = 0, + nw: Tile = None, + n: Tile = None, + ne: Tile = None, + e: Tile = None, + se: Tile = None, + s: Tile = None, + sw: Tile = None, + w: Tile = None, + ): + rendered = self.image.copy() + + if nw: + rendered.paste(nw.image, (0, 0)) + if n: + rendered.paste(n.image, (32, 0)) + rendered.paste(n.image, (64, 0)) + if ne: + rendered.paste(ne.image, (96, 0)) + if e: + rendered.paste(e.image, (96, 32)) + rendered.paste(e.image, (96, 64)) + if se: + rendered.paste(se.image, (96, 96)) + if s: + rendered.paste(s.image, (32, 96)) + rendered.paste(s.image, (64, 96)) + if sw: + rendered.paste(sw.image, (0, 96)) + if w: + rendered.paste(w.image, (0, 32)) + rendered.paste(w.image, (0, 64)) + + if size == rendered.width and size == rendered.height: + return rendered + return rendered.resize((size, size)) + + +@dataclass +class ImageTileSet(TileSet): + image_dir: Path + tile_size: int = 128 + + _cache = {} + + def _load_images(self, char: str, name: str): + paths = [] + lastkey = None + key = None + for imgfile in sorted(self.image_dir.glob(f"{name}_*.png")): + (terrain_name, *parts) = imgfile.stem.rsplit("_") + key = terrain_name + if parts[0] in ("edge", "corner"): + key = "_".join([terrain_name, *parts[:-1]]) + if not lastkey: + lastkey = key + if lastkey != key: + self.add(*[self._get_or_create_tile(char=char, name=lastkey, paths=paths)]) + lastkey = key + paths = [] + paths.append(imgfile) + if key: + self.add(*[self._get_or_create_tile(char=char, name=key, paths=paths)]) + + def load(self): + self.tiles = {} + for char, name in self.character_map.items(): + self._load_images(char, name) + + def _get_or_create_tile(self, char: str, name: str, paths: List[Path]): + if name in self._cache: + return self._cache[name] + buffer = None + if not paths: + buffer = Image.new("RGB", (self.tile_size, self.tile_size)) + ImageDraw.Draw(buffer).text((3, 3), name, (255, 255, 255)) + self._cache[name] = ImageTile(char=char, name=name, paths=paths, buffer=buffer) + return self._cache[name] + + def _get_overlays(self, position: Position, adjacent: List[Position] = []): + terrain = position.value.terrain_name + (nw_terrain, n_terrain, ne_terrain, e_terrain, se_terrain, s_terrain, sw_terrain, w_terrain) = [ + a.value.terrain_name for a in adjacent + ] + n = f"{terrain}_edge_{n_terrain}_n" if n_terrain != terrain else None + s = f"{terrain}_edge_{s_terrain}_s" if s_terrain != terrain else None + e = f"{terrain}_edge_{e_terrain}_e" if e_terrain != terrain else None + w = f"{terrain}_edge_{w_terrain}_w" if w_terrain != terrain else None + nw = f"{n}w" if n_terrain != terrain and w_terrain == n_terrain else n + nw = f"{terrain}_corner_nw" if n_terrain == terrain and nw_terrain != terrain else nw or w + sw = f"{s}w" if s_terrain != terrain and w_terrain == s_terrain else s + sw = f"{terrain}_corner_sw" if s_terrain == terrain and sw_terrain != terrain else sw or w + ne = f"{n}e" if n_terrain != terrain and e_terrain == n_terrain else n + ne = f"{terrain}_corner_ne" if n_terrain == terrain and ne_terrain != terrain else ne or e + se = f"{s}e" if s_terrain != terrain and e_terrain == s_terrain else s + se = f"{terrain}_corner_se" if s_terrain == terrain and se_terrain != terrain else se or e + + return (nw, n, ne, e, se, s, sw, w) + + def render_tile(self, position, adjacent): + return position.value.render( + self.tile_size, *[self._cache.get(overlay) for overlay in self._get_overlays(position, adjacent)] + ) + + +class TileSetManager: + def __init__(self, config_dir: Path): + self.config_dir = config_dir + self._available = {} + self._load_tilesets() + + @property + def available(self): + return {"console": self.console_map, "colorized": self.console_map_colorized, **self._available} + + def _load_tilesets(self): + self._available = {} + for config_file in self.config_dir.rglob("tileset.toml"): + config = tomllib.loads(config_file.read_bytes().decode()) + config["config_dir"] = config_file.parent + self._available[config["config_dir"].name] = config + + def load(self, name: str): + config = self.available[name] + tileset = ImageTileSet( + image_dir=config["config_dir"], + name=config["tileset"]["name"], + desc=config["tileset"]["desc"], + tile_size=config["tileset"].get("size", DEFAULT_TILE_SIZE_IN_PIXELS), + character_map=config["legend"], + ) + tileset.load() + return tileset + + @cached_property + def console_map(self): + ts = TileSet(name="console", desc="Tiles used for input and text rendering.") + ts.add(*[Tile(char=key, name=value) for key, value in ts.character_map.items()]) + return ts + + @cached_property + def console_map_colorized(self): + ts = ColorizedTileSet(name="colorized", desc="Colorized ASCII.") + ts.add( + *[ + ColorizedTile(char=key, name=value, color=COLOR_MAP.get(key, "grey")) + for key, value in ts.character_map.items() + ] + ) + return ts diff --git a/src/tilesets/test/background_1.png b/src/tilesets/test/background_1.png new file mode 100644 index 0000000..3dd4d5a Binary files /dev/null and b/src/tilesets/test/background_1.png differ diff --git a/src/tilesets/test/grass_1.png b/src/tilesets/test/grass_1.png new file mode 100644 index 0000000..5697a7f Binary files /dev/null and b/src/tilesets/test/grass_1.png differ diff --git a/src/tilesets/test/grass_2.png b/src/tilesets/test/grass_2.png new file mode 100644 index 0000000..9cab889 Binary files /dev/null and b/src/tilesets/test/grass_2.png differ diff --git a/src/tilesets/test/ground_1.png b/src/tilesets/test/ground_1.png new file mode 100644 index 0000000..11a8950 Binary files /dev/null and b/src/tilesets/test/ground_1.png differ diff --git a/src/tilesets/test/mountain_1.png b/src/tilesets/test/mountain_1.png new file mode 100644 index 0000000..2ac9078 Binary files /dev/null and b/src/tilesets/test/mountain_1.png differ diff --git a/src/tilesets/test/tileset.toml b/src/tilesets/test/tileset.toml new file mode 100644 index 0000000..12dc187 --- /dev/null +++ b/src/tilesets/test/tileset.toml @@ -0,0 +1,11 @@ +[tileset] +name = "Test Tiles" +desc = "Testing edge-aware tiles" +size = 128 + +[legend] +" " = "background" +"," = "grass" +"_" = "water" +"." = "ground" +"m" = "mountain" diff --git a/src/tilesets/test/water_1.png b/src/tilesets/test/water_1.png new file mode 100644 index 0000000..459c48d Binary files /dev/null and b/src/tilesets/test/water_1.png differ diff --git a/src/tilesets/test/water_corner_ne_1.png b/src/tilesets/test/water_corner_ne_1.png new file mode 100644 index 0000000..3565dff Binary files /dev/null and b/src/tilesets/test/water_corner_ne_1.png differ diff --git a/src/tilesets/test/water_corner_nw_1.png b/src/tilesets/test/water_corner_nw_1.png new file mode 100644 index 0000000..6a8cc03 Binary files /dev/null and b/src/tilesets/test/water_corner_nw_1.png differ diff --git a/src/tilesets/test/water_corner_se_1.png b/src/tilesets/test/water_corner_se_1.png new file mode 100644 index 0000000..b5a69d3 Binary files /dev/null and b/src/tilesets/test/water_corner_se_1.png differ diff --git a/src/tilesets/test/water_corner_sw_1.png b/src/tilesets/test/water_corner_sw_1.png new file mode 100644 index 0000000..676a73e Binary files /dev/null and b/src/tilesets/test/water_corner_sw_1.png differ diff --git a/src/tilesets/test/water_edge_grass_e_1.png b/src/tilesets/test/water_edge_grass_e_1.png new file mode 100644 index 0000000..38cb593 Binary files /dev/null and b/src/tilesets/test/water_edge_grass_e_1.png differ diff --git a/src/tilesets/test/water_edge_grass_n_1.png b/src/tilesets/test/water_edge_grass_n_1.png new file mode 100644 index 0000000..bbecd27 Binary files /dev/null and b/src/tilesets/test/water_edge_grass_n_1.png differ diff --git a/src/tilesets/test/water_edge_grass_ne_1.png b/src/tilesets/test/water_edge_grass_ne_1.png new file mode 100644 index 0000000..2771300 Binary files /dev/null and b/src/tilesets/test/water_edge_grass_ne_1.png differ diff --git a/src/tilesets/test/water_edge_grass_nw_1.png b/src/tilesets/test/water_edge_grass_nw_1.png new file mode 100644 index 0000000..b8e7244 Binary files /dev/null and b/src/tilesets/test/water_edge_grass_nw_1.png differ diff --git a/src/tilesets/test/water_edge_grass_s_1.png b/src/tilesets/test/water_edge_grass_s_1.png new file mode 100644 index 0000000..9cdc8fa Binary files /dev/null and b/src/tilesets/test/water_edge_grass_s_1.png differ diff --git a/src/tilesets/test/water_edge_grass_se_1.png b/src/tilesets/test/water_edge_grass_se_1.png new file mode 100644 index 0000000..7533a73 Binary files /dev/null and b/src/tilesets/test/water_edge_grass_se_1.png differ diff --git a/src/tilesets/test/water_edge_grass_sw_1.png b/src/tilesets/test/water_edge_grass_sw_1.png new file mode 100644 index 0000000..5836160 Binary files /dev/null and b/src/tilesets/test/water_edge_grass_sw_1.png differ diff --git a/src/tilesets/test/water_edge_grass_w_1.png b/src/tilesets/test/water_edge_grass_w_1.png new file mode 100644 index 0000000..1cd6c60 Binary files /dev/null and b/src/tilesets/test/water_edge_grass_w_1.png differ diff --git a/test/test_mapper.py b/test/test_mapper.py new file mode 100644 index 0000000..d4f657b --- /dev/null +++ b/test/test_mapper.py @@ -0,0 +1,46 @@ +from io import StringIO +from pathlib import Path +from textwrap import dedent + +import pytest + +from tilemapper import battlemap, tileset + + +@pytest.fixture +def manager(): + return tileset.TileSetManager(Path(__file__).parent / "fixtures") + + +@pytest.fixture +def sample_map(): + return dedent( + """ + ........ 1 + ........ ........ 2 + ........ ........ + .......L...D... D + ........ .... ... + ........ ...d ... + . + .........S. 3 + 5 . . + ....S... ...d.... 4 + ........ ........ + ........ ........ + + """ + ) + + +def test_tileset_loader(manager): + assert manager.console_map.name in manager.available + + +def test_renderer(manager, sample_map): + test_map = battlemap.BattleMap("test map", source=StringIO(sample_map), tileset=manager.console_map) + test_map.load() + assert test_map.width == 21 + assert test_map.height == 12 + assert test_map.source_data == sample_map.strip("\n") + assert str(test_map) == sample_map.strip("\n")