diff --git a/data/maps/sample_arena.yaml b/data/maps/sample_arena.yaml new file mode 100644 index 0000000..860eb4e --- /dev/null +++ b/data/maps/sample_arena.yaml @@ -0,0 +1,48 @@ +name: "Arène d'essai" +square_size_ft: 5 + +terrain: | + #################### + #......######......# + #..C...######...C..# + #......~..~........# + #..TT..~..~...TT...# + #......~..~........# + #..C............C..# + #################### + +legend: + "#": { type: wall, move_cost: null, blocks_los: true } + ".": { type: floor, move_cost: 1 } + "T": { type: rubble, move_cost: 2 } + "~": { type: water, move_cost: 2 } + "C": { type: pillar, move_cost: null, cover: true } + +elevation: | + .................... + ..1111.............. + ..1111.............. + .................... + .................... + .................... + .................... + .................... + +zones: | + .................... + .AAAA..........BBBB. + .AAAA..........BBBB. + .................... + .................... + .................... + .................... + .................... + +markers: + autel: [1, 16] + brasier_ouest: [6, 5] + brasier_est: [6, 14] + +deployment: + players: A + monsters: B diff --git a/src/pf1e_simulator/grid.py b/src/pf1e_simulator/grid.py new file mode 100644 index 0000000..a37eb4e --- /dev/null +++ b/src/pf1e_simulator/grid.py @@ -0,0 +1,123 @@ +"""Square grid: PF1e distances, movement costs, and Dijkstra pathing. + +Diagonal movement alternates 1-2-1-2 squares (the 5-10-5-10 ft rule), so the +Dijkstra state carries the parity of diagonals used. A diagonal step into +difficult terrain costs a flat 3 squares and still counts as a diagonal for +the alternation (table convention). A diagonal step requires both flanking +orthogonal squares to be terrain-passable (no cutting wall corners); creature +occupancy never blocks a diagonal, and occupied squares cannot be entered in +Phase 0 (moving through allies is a later refinement). +""" + +from __future__ import annotations + +import heapq +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pf1e_simulator.map import MapSpec, Pos, TerrainType + +_DIFFICULT_TERRAIN_COST = 2 # rubble, water, etc. count as 2 squares (PF1e) + +_DIRECTIONS: tuple[Pos, ...] = ( + (-1, -1), + (-1, 0), + (-1, 1), + (0, -1), + (0, 1), + (1, -1), + (1, 0), + (1, 1), +) + + +class Grid: + """Terrain view of a MapSpec with movement and distance queries.""" + + def __init__(self, cells: tuple[tuple[TerrainType, ...], ...]) -> None: + self._cells = cells + self._height = len(cells) + self._width = len(cells[0]) if cells else 0 + + @classmethod + def from_spec(cls, spec: MapSpec) -> Grid: + cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain) + return cls(cells) + + @property + def height(self) -> int: + return self._height + + @property + def width(self) -> int: + return self._width + + def in_bounds(self, pos: Pos) -> bool: + row, col = pos + return 0 <= row < self._height and 0 <= col < self._width + + def terrain_at(self, pos: Pos) -> TerrainType: + row, col = pos + return self._cells[row][col] + + def passable(self, pos: Pos) -> bool: + return self.in_bounds(pos) and self.terrain_at(pos).move_cost is not None + + def distance(self, a: Pos, b: Pos) -> int: + """Distance in squares under the 5-10-5-10 diagonal rule.""" + d_row = abs(a[0] - b[0]) + d_col = abs(a[1] - b[1]) + return max(d_row, d_col) + min(d_row, d_col) // 2 + + def step_cost(self, frm: Pos, to: Pos, diagonals_used: int) -> int: + """Cost in squares of one step; diagonals_used is the count so far.""" + cost = self.terrain_at(to).move_cost + if cost is None: + msg = f"cannot step into impassable square {to}" + raise ValueError(msg) + is_diagonal = frm[0] != to[0] and frm[1] != to[1] + if not is_diagonal: + return cost + if cost >= _DIFFICULT_TERRAIN_COST: + return 3 + return 1 + (diagonals_used % 2) + + def diagonal_allowed(self, frm: Pos, to: Pos) -> bool: + flank_a = (frm[0], to[1]) + flank_b = (to[0], frm[1]) + return self.passable(flank_a) and self.passable(flank_b) + + def reachable(self, start: Pos, budget: int, blocked: frozenset[Pos]) -> dict[Pos, int]: + """Cheapest movement cost per reachable square, capped at budget. + + `blocked` holds creature-occupied squares: unenterable, but never + affecting diagonal_allowed. The start square is included at cost 0. + """ + best_per_state: dict[tuple[Pos, int], int] = {(start, 0): 0} + results: dict[Pos, int] = {start: 0} + heap: list[tuple[int, Pos, int]] = [(0, start, 0)] + while heap: + cost, pos, parity = heapq.heappop(heap) + if cost > best_per_state.get((pos, parity), cost): + continue + row, col = pos + for d_row, d_col in _DIRECTIONS: + nxt = (row + d_row, col + d_col) + if nxt == start or nxt in blocked or not self.passable(nxt): + continue + is_diagonal = d_row != 0 and d_col != 0 + if is_diagonal and not self.diagonal_allowed(pos, nxt): + continue + new_cost = cost + self.step_cost(pos, nxt, parity) + if new_cost > budget: + continue + new_parity = (parity + 1) % 2 if is_diagonal else parity + state = (nxt, new_parity) + known = best_per_state.get(state) + if known is None or new_cost < known: + best_per_state[state] = new_cost + current = results.get(nxt) + if current is None or new_cost < current: + results[nxt] = new_cost + heapq.heappush(heap, (new_cost, nxt, new_parity)) + return results diff --git a/src/pf1e_simulator/los.py b/src/pf1e_simulator/los.py new file mode 100644 index 0000000..fbbc4e2 --- /dev/null +++ b/src/pf1e_simulator/los.py @@ -0,0 +1,129 @@ +"""Corner-rule line of effect and cover. + +Squares are unit cells on an integer lattice: cell (row, col) spans +x in [col, col+1], y in [row, row+1]. A line is blocked when its RELATIVE +INTERIOR touches a blocking square's closure (interior, border, or vertex); +lines merely starting or ending on a blocking vertex do not count. All math +is exact integer arithmetic: parameter t is kept as (num, den) pairs, no +floats anywhere. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + from pf1e_simulator.grid import Grid + from pf1e_simulator.map import Pos, TerrainType + +Pt = tuple[int, int] # lattice point (x, y) = (col, row) +_Rat = tuple[int, int] # exact rational as (numerator, denominator), den > 0 + + +def _corners(pos: Pos) -> tuple[Pt, Pt, Pt, Pt]: + row, col = pos + return ((col, row), (col + 1, row), (col, row + 1), (col + 1, row + 1)) + + +def _norm(num: int, den: int) -> _Rat: + return (num, den) if den > 0 else (-num, -den) + + +def _le(a: _Rat, b: _Rat) -> bool: + return a[0] * b[1] <= b[0] * a[1] + + +def _lt(a: _Rat, b: _Rat) -> bool: + return a[0] * b[1] < b[0] * a[1] + + +def _slab(p1: Pt, delta: Pt, lo: int, hi: int, axis: int) -> tuple[_Rat, _Rat] | None: + """Closed t-interval where p1 + t*delta lies within [lo, hi] on `axis`.""" + start = p1[axis] + step = delta[axis] + if step == 0: + return ((0, 1), (1, 1)) if lo <= start <= hi else None + t1 = _norm(lo - start, step) + t2 = _norm(hi - start, step) + return (t1, t2) if _le(t1, t2) else (t2, t1) + + +def _segment_hits_cell(p1: Pt, p2: Pt, row: int, col: int) -> bool: + """True iff the relative interior of p1->p2 touches the closed cell.""" + delta = (p2[0] - p1[0], p2[1] - p1[1]) + if delta == (0, 0): + return False + interval_x = _slab(p1, delta, col, col + 1, 0) + if interval_x is None: + return False + interval_y = _slab(p1, delta, row, row + 1, 1) + if interval_y is None: + return False + t_low = interval_x[0] if _le(interval_y[0], interval_x[0]) else interval_y[0] + t_high = interval_x[1] if _le(interval_x[1], interval_y[1]) else interval_y[1] + return _le(t_low, t_high) and _lt((0, 1), t_high) and _lt(t_low, (1, 1)) + + +class _LineGrid: + """Bound view for corner-line checks between two squares of one grid.""" + + def __init__( + self, + grid: Grid, + a: Pos, + b: Pos, + blocking: Callable[[TerrainType], bool], + ) -> None: + self._grid = grid + self._exclude = frozenset({a, b}) + self._blocking = blocking + + def blocked(self, p1: Pt, p2: Pt) -> bool: + min_x = min(p1[0], p2[0]) + max_x = max(p1[0], p2[0]) + min_y = min(p1[1], p2[1]) + max_y = max(p1[1], p2[1]) + for col in range(min_x - 1, max_x + 1): + for row in range(min_y - 1, max_y + 1): + pos = (row, col) + if pos in self._exclude or not self._grid.in_bounds(pos): + continue + if self._blocking(self._grid.terrain_at(pos)) and _segment_hits_cell( + p1, p2, row, col + ): + return True + return False + + +def _grants_cover(terrain: TerrainType) -> bool: + return terrain.cover or terrain.blocks_los + + +def has_line_of_effect(grid: Grid, a: Pos, b: Pos) -> bool: + """LoE iff some corner of a reaches some corner of b unblocked.""" + if a == b: + return True + lines = _LineGrid(grid, a, b, lambda terrain: terrain.blocks_los) + return any( + not lines.blocked(corner_a, corner_b) + for corner_a in _corners(a) + for corner_b in _corners(b) + ) + + +def has_cover(grid: Grid, attacker: Pos, target: Pos, *, ranged: bool) -> bool: + """Cover iff every attacker corner has at least one blocked target corner. + + The attacker picks their single best corner (PF1e ranged cover rule); + Phase 0 documented deviation: melee reuses the same corner rule. + """ + if attacker == target: + return False + _ = ranged + lines = _LineGrid(grid, attacker, target, _grants_cover) + return not any( + all(not lines.blocked(corner_a, corner_t) for corner_t in _corners(target)) + for corner_a in _corners(attacker) + ) diff --git a/src/pf1e_simulator/map.py b/src/pf1e_simulator/map.py new file mode 100644 index 0000000..d4676c0 --- /dev/null +++ b/src/pf1e_simulator/map.py @@ -0,0 +1,183 @@ +"""Map YAML loading and validation. + +Maps are multi-layer ASCII: a mandatory `terrain` layer plus optional +`elevation` and `zones` layers of identical dimensions, named `markers`, and +a `deployment` section binding sides to zone letters. One char = one square, +row 0 is the top line of the ASCII block. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +if TYPE_CHECKING: + from pathlib import Path + +Pos = tuple[int, int] # (row, col), 0-indexed + +_ELEVATION_CHARS = frozenset("0123456789.") # digit = height, '.' = ground level + + +class MapValidationError(Exception): + """Raised when a map YAML file fails validation; carries path and reason.""" + + def __init__(self, path: Path, reason: str) -> None: + super().__init__(f"{path}: {reason}") + self.path = path + self.reason = reason + + +class TerrainType(BaseModel): + """What one square means for movement and sight lines.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + type: str + move_cost: int | None = Field(default=None, ge=1) + cover: bool = False + blocks_los: bool = False + + +class MapSpec(BaseModel): + """A fully validated map, ready for Grid construction.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + square_size_ft: int = Field(default=5, gt=0) + terrain: tuple[str, ...] + legend: dict[str, TerrainType] + elevation: tuple[str, ...] | None = None + zones: tuple[str, ...] | None = None + markers: dict[str, Pos] = Field(default_factory=dict) + deployment: dict[str, str] = Field(default_factory=dict) + + +class _MapFile(BaseModel): + """Raw YAML shape; cross-layer checks live in load_map.""" + + model_config = ConfigDict(extra="forbid") + + name: str + square_size_ft: int = Field(default=5, gt=0) + terrain: str + legend: dict[str, TerrainType] + elevation: str | None = None + zones: str | None = None + markers: dict[str, Pos] = Field(default_factory=dict) + deployment: dict[str, str] = Field(default_factory=dict) + + +def _split_rows(block: str, what: str, path: Path) -> tuple[str, ...]: + rows = tuple(line.rstrip() for line in block.splitlines() if line.strip()) + if not rows: + msg = f"{what} layer is empty" + raise MapValidationError(path, msg) + if len({len(row) for row in rows}) != 1: + msg = f"{what} layer has ragged rows" + raise MapValidationError(path, msg) + return rows + + +def _check_layer_dimensions( + rows: tuple[str, ...], what: str, height: int, width: int, path: Path +) -> None: + if len(rows) != height or len(rows[0]) != width: + msg = f"{what} layer dimensions do not match terrain" + raise MapValidationError(path, msg) + + +def _optional_layer(block: str | None, what: str, path: Path) -> tuple[str, ...] | None: + """Split an optional ASCII layer, or None when the layer is absent.""" + return _split_rows(block, what, path) if block else None + + +def _check_elevation(elevation: tuple[str, ...], height: int, width: int, path: Path) -> None: + _check_layer_dimensions(elevation, "elevation", height, width, path) + bad = sorted({ch for row in elevation for ch in row if ch not in _ELEVATION_CHARS}) + if bad: + msg = f"elevation chars must be digits or '.': {bad}" + raise MapValidationError(path, msg) + + +def _check_markers(markers: dict[str, Pos], height: int, width: int, path: Path) -> None: + for marker_name, (row, col) in markers.items(): + if not (0 <= row < height and 0 <= col < width): + msg = f"marker {marker_name!r} out of bounds: {(row, col)}" + raise MapValidationError(path, msg) + + +def _check_deployment( + deployment: dict[str, str], zones: tuple[str, ...] | None, path: Path +) -> None: + if not deployment: + return + if zones is None: + msg = "deployment declared but no zones layer" + raise MapValidationError(path, msg) + available = {ch for row in zones for ch in row if ch != "."} + missing = sorted({letter for letter in deployment.values() if letter not in available}) + if missing: + msg = f"deployment zones absent from zones layer: {missing}" + raise MapValidationError(path, msg) + + +def load_map(path: Path) -> MapSpec: + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + msg = f"cannot read file: {exc}" + raise MapValidationError(path, msg) from exc + try: + data: object = yaml.safe_load(text) + except yaml.YAMLError as exc: + msg = f"invalid YAML: {exc}" + raise MapValidationError(path, msg) from exc + try: + raw = _MapFile.model_validate(data) + except ValidationError as exc: + raise MapValidationError(path, str(exc)) from exc + + terrain = _split_rows(raw.terrain, "terrain", path) + height, width = len(terrain), len(terrain[0]) + unknown = sorted({ch for row in terrain for ch in row if ch not in raw.legend}) + if unknown: + msg = f"terrain chars missing from legend: {unknown}" + raise MapValidationError(path, msg) + + elevation = _optional_layer(raw.elevation, "elevation", path) + if elevation is not None: + _check_elevation(elevation, height, width, path) + + zones = _optional_layer(raw.zones, "zones", path) + if zones is not None: + _check_layer_dimensions(zones, "zones", height, width, path) + + _check_markers(raw.markers, height, width, path) + _check_deployment(raw.deployment, zones, path) + + return MapSpec( + name=raw.name, + square_size_ft=raw.square_size_ft, + terrain=terrain, + legend=raw.legend, + elevation=elevation, + zones=zones, + markers=raw.markers, + deployment=raw.deployment, + ) + + +def zone_cells(spec: MapSpec, letter: str) -> list[Pos]: + """Cells of a deployment zone, sorted by (row, col); empty if no zones layer.""" + if spec.zones is None: + return [] + return [ + (row, col) + for row, line in enumerate(spec.zones) + for col, ch in enumerate(line) + if ch == letter + ] diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100644 index 0000000..642ffd9 --- /dev/null +++ b/tests/test_grid.py @@ -0,0 +1,114 @@ +"""Tests for the square grid: distance, step costs, corner rule, Dijkstra.""" + +from __future__ import annotations + +from pf1e_simulator.grid import Grid +from pf1e_simulator.map import MapSpec, TerrainType + + +def make_grid(rows: list[str]) -> Grid: + legend = { + "#": TerrainType(type="wall", move_cost=None, blocks_los=True), + ".": TerrainType(type="floor", move_cost=1), + "T": TerrainType(type="rubble", move_cost=2), + "C": TerrainType(type="pillar", move_cost=None, cover=True), + } + spec = MapSpec(name="test", terrain=tuple(rows), legend=legend) + return Grid.from_spec(spec) + + +def test_distance_diagonal_table() -> None: + grid = make_grid([".....", ".....", ".....", ".....", "....."]) + assert grid.distance((0, 0), (1, 1)) == 1 + assert grid.distance((0, 0), (2, 2)) == 3 + assert grid.distance((0, 0), (3, 2)) == 4 + assert grid.distance((0, 0), (0, 3)) == 3 + assert grid.distance((2, 1), (0, 0)) == 2 + + +def test_step_cost_orthogonal_uses_entered_square() -> None: + grid = make_grid([".T."]) + assert grid.step_cost((0, 0), (0, 1), 0) == 2 + assert grid.step_cost((0, 1), (0, 2), 0) == 1 + + +def test_step_cost_diagonal_alternates() -> None: + grid = make_grid(["...", "...", "..."]) + assert grid.step_cost((0, 0), (1, 1), 0) == 1 + assert grid.step_cost((1, 1), (2, 2), 1) == 2 + assert grid.step_cost((1, 1), (2, 2), 2) == 1 + + +def test_step_cost_diagonal_into_difficult_is_flat_three() -> None: + grid = make_grid(["..", ".T"]) + assert grid.step_cost((0, 0), (1, 1), 0) == 3 + assert grid.step_cost((0, 0), (1, 1), 1) == 3 + + +def test_diagonal_forbidden_past_wall_corner() -> None: + grid = make_grid([ + ".#.", + ".#.", + "...", + ]) + assert grid.diagonal_allowed((2, 1), (1, 0)) is False + assert grid.diagonal_allowed((2, 1), (1, 2)) is False + + +def test_diagonal_allowed_with_both_flanks_clear() -> None: + grid = make_grid([ + ".#.", + "...", + "...", + ]) + assert grid.diagonal_allowed((2, 1), (1, 0)) is True + + +def test_reachable_open_floor_costs() -> None: + grid = make_grid([".....", ".....", ".....", ".....", "....."]) + reached = grid.reachable((0, 0), 10, frozenset()) + assert reached[(0, 0)] == 0 + assert reached[(1, 1)] == 1 + assert reached[(2, 2)] == 3 + assert reached[(3, 3)] == 4 + assert reached[(4, 4)] == 6 + assert reached[(0, 4)] == 4 + + +def test_reachable_respects_budget() -> None: + grid = make_grid([".....", ".....", ".....", ".....", "....."]) + reached = grid.reachable((0, 0), 2, frozenset()) + assert (1, 1) in reached + assert (2, 2) not in reached + + +def test_reachable_diagonal_into_rubble_costs_three() -> None: + grid = make_grid(["...", ".T.", "..."]) + reached = grid.reachable((0, 0), 6, frozenset()) + assert reached[(1, 1)] == 3 + + +def test_reachable_through_rubble_cheaper_than_around() -> None: + grid = make_grid(["...", ".T.", "..."]) + reached = grid.reachable((1, 0), 6, frozenset()) + assert reached[(1, 2)] == 3 + + +def test_reachable_creatures_block_entry_not_diagonals() -> None: + grid = make_grid(["...", "...", "..."]) + reached = grid.reachable((1, 1), 1, frozenset({(1, 0)})) + assert (1, 0) not in reached + assert reached[(0, 2)] == 1 + + +def test_reachable_paths_around_creatures() -> None: + grid = make_grid(["...", ".#.", "..."]) + reached = grid.reachable((1, 0), 6, frozenset()) + assert (1, 1) not in reached + assert reached[(1, 2)] == 4 + + +def test_reachable_cannot_enter_wall_squares() -> None: + grid = make_grid(["..", ".C"]) + reached = grid.reachable((0, 0), 9, frozenset()) + assert (1, 1) not in reached diff --git a/tests/test_los.py b/tests/test_los.py new file mode 100644 index 0000000..3b61095 --- /dev/null +++ b/tests/test_los.py @@ -0,0 +1,90 @@ +"""Tests for corner-rule line of effect and cover (los.py). + +Geometry convention: a line is blocked when its relative interior touches a +blocking square (interior, border, or vertex); lines that merely start or end +on a blocking square's vertex do not count as passing through it. +""" + +from __future__ import annotations + +from pf1e_simulator.grid import Grid +from pf1e_simulator.los import has_cover, has_line_of_effect +from pf1e_simulator.map import MapSpec, TerrainType + + +def make_grid(rows: list[str]) -> Grid: + legend = { + "#": TerrainType(type="wall", move_cost=None, blocks_los=True), + ".": TerrainType(type="floor", move_cost=1), + "T": TerrainType(type="rubble", move_cost=2), + "C": TerrainType(type="pillar", move_cost=None, cover=True), + } + spec = MapSpec(name="test", terrain=tuple(rows), legend=legend) + return Grid.from_spec(spec) + + +def test_los_open_field() -> None: + grid = make_grid(["...", "...", "..."]) + assert has_line_of_effect(grid, (0, 0), (2, 2)) is True + + +def test_los_same_square() -> None: + grid = make_grid(["."]) + assert has_line_of_effect(grid, (0, 0), (0, 0)) is True + + +def test_los_blocked_by_wall_between() -> None: + grid = make_grid([".#.", "..."]) + assert has_line_of_effect(grid, (0, 0), (0, 2)) is False + + +def test_los_through_doorway_gap() -> None: + grid = make_grid(["##.##", "##.##"]) + assert has_line_of_effect(grid, (0, 2), (1, 2)) is True + + +def test_los_pinched_vertex_stays_open() -> None: + grid = make_grid([".#.", "#.."]) + assert has_line_of_effect(grid, (0, 0), (1, 1)) is True + + +def test_los_blocked_past_wall_line() -> None: + grid = make_grid(["...#.", "....."]) + assert has_line_of_effect(grid, (0, 0), (0, 4)) is False + + +def test_cover_open_field() -> None: + grid = make_grid([".....", "....."]) + assert has_cover(grid, (0, 0), (0, 4), ranged=True) is False + + +def test_cover_pillar_between() -> None: + grid = make_grid([".....", "..C..", "....."]) + assert has_cover(grid, (1, 0), (1, 4), ranged=True) is True + + +def test_no_cover_when_pillar_off_axis() -> None: + grid = make_grid(["..C..", "....."]) + assert has_cover(grid, (1, 0), (1, 4), ranged=True) is False + + +def test_no_cover_adjacent_with_wall_behind_target() -> None: + grid = make_grid(["..#"]) + assert has_cover(grid, (0, 0), (0, 1), ranged=True) is False + + +def test_cover_diagonal_past_wall_vertex() -> None: + grid = make_grid([".#.", "#.."]) + assert has_cover(grid, (0, 0), (1, 1), ranged=True) is True + + +def test_cover_melee_uses_same_corner_rule_in_phase0() -> None: + grid = make_grid([".#.", "#.."]) + assert has_cover(grid, (0, 0), (1, 1), ranged=False) is True + open_grid = make_grid(["...", "..."]) + assert has_cover(open_grid, (0, 0), (1, 1), ranged=False) is False + + +def test_cover_same_square_is_false() -> None: + grid = make_grid(["."]) + assert has_cover(grid, (0, 0), (0, 0), ranged=True) is False diff --git a/tests/test_map.py b/tests/test_map.py new file mode 100644 index 0000000..63a110e --- /dev/null +++ b/tests/test_map.py @@ -0,0 +1,93 @@ +"""Tests for the map YAML schema (map.py).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from pf1e_simulator.map import MapValidationError, load_map, zone_cells + +MAPS_DIR = Path(__file__).resolve().parents[1] / "data" / "maps" + + +def _write(tmp_path: Path, body: str) -> Path: + target = tmp_path / "map.yaml" + target.write_text(body, encoding="utf-8") + return target + + +def _minimal_map() -> str: + return """ +name: "Test" +terrain: | + ### + #.# + ### +legend: + "#": { type: wall, blocks_los: true } + ".": { type: floor, move_cost: 1 } +""" + + +def test_sample_arena_loads() -> None: + spec = load_map(MAPS_DIR / "sample_arena.yaml") + assert spec.square_size_ft == 5 + assert len(spec.terrain) == 8 + assert all(len(row) == 20 for row in spec.terrain) + assert spec.legend["#"].move_cost is None + assert spec.legend["#"].blocks_los is True + assert spec.legend["C"].cover is True + assert spec.legend["T"].move_cost == 2 + assert spec.deployment == {"players": "A", "monsters": "B"} + assert spec.markers["autel"] == (1, 16) + + +def test_sample_arena_zones_and_elevation() -> None: + spec = load_map(MAPS_DIR / "sample_arena.yaml") + assert spec.elevation is not None + assert spec.elevation[1][2] == "1" + cells_a = zone_cells(spec, "A") + assert cells_a == [ + (1, 1), (1, 2), (1, 3), (1, 4), + (2, 1), (2, 2), (2, 3), (2, 4), + ] + assert len(zone_cells(spec, "B")) == 8 + + +def test_minimal_map_without_optional_layers(tmp_path: Path) -> None: + spec = load_map(_write(tmp_path, _minimal_map())) + assert spec.elevation is None + assert spec.zones is None + assert spec.markers == {} + assert zone_cells(spec, "A") == [] + + +def test_rejects_ragged_rows(tmp_path: Path) -> None: + body = _minimal_map().replace(" #.#\n", " #.##\n") + with pytest.raises(MapValidationError): + load_map(_write(tmp_path, body)) + + +def test_rejects_char_missing_from_legend(tmp_path: Path) -> None: + body = _minimal_map().replace(" #.#\n", " #x#\n") + with pytest.raises(MapValidationError): + load_map(_write(tmp_path, body)) + + +def test_rejects_elevation_dimension_mismatch(tmp_path: Path) -> None: + body = _minimal_map() + "elevation: |\n 000\n 00\n" + with pytest.raises(MapValidationError): + load_map(_write(tmp_path, body)) + + +def test_rejects_marker_out_of_bounds(tmp_path: Path) -> None: + body = _minimal_map() + "markers:\n autel: [9, 1]\n" + with pytest.raises(MapValidationError): + load_map(_write(tmp_path, body)) + + +def test_rejects_deployment_without_zone(tmp_path: Path) -> None: + body = _minimal_map() + "zones: |\n ...\n .A.\n ...\ndeployment:\n players: B\n" + with pytest.raises(MapValidationError): + load_map(_write(tmp_path, body))