"""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, field_validator from yaml.nodes import ScalarNode if TYPE_CHECKING: from collections.abc import Hashable from pathlib import Path from yaml.nodes import MappingNode Pos = tuple[int, int] # (row, col), 0-indexed _ELEVATION_CHARS = frozenset("0123456789.") # digit = height, '.' = ground level # PF1e (SRD, Measuring Distance): one grid square is 5 feet. The engine's # whole geometry (5-10-5 diagonals, step costs, ranges, auras) is hard-coded # to that scale, so a map declaring any other square size is a data error -- # fail fast at load instead of silently mis-scaling. _SQUARE_SIZE_FT = 5 _COVER_BONUS_LEVELS = frozenset({0, 2, 4, 8}) # PF1e: none / partial / standard / improved 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 _NoDuplicateLoader(yaml.SafeLoader): """SafeLoader that rejects duplicate mapping keys (PyYAML is last-wins).""" def _construct_mapping( loader: _NoDuplicateLoader, node: MappingNode, *, deep: bool = False ) -> dict[Hashable, object]: seen: set[str] = set() for key_node, _ in node.value: if isinstance(key_node, ScalarNode): key = loader.construct_scalar(key_node) if key in seen: msg = f"duplicate mapping key: {key!r}" raise yaml.constructor.ConstructorError(None, None, msg, key_node.start_mark) seen.add(key) return yaml.constructor.SafeConstructor.construct_mapping(loader, node, deep) _NoDuplicateLoader.add_constructor( yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping ) class TerrainType(BaseModel): """What one square means for movement and sight lines. ``move_cost``: positive int = passable; ``None`` -- explicit or omitted (the field default) -- means IMPASSABLE. A passable terrain must always declare its cost; omitting the field is never a silent walkable default. """ model_config = ConfigDict(frozen=True, extra="forbid") type: str move_cost: int | None = Field(default=None, ge=1) cover: bool = False cover_bonus: int = 0 blocks_los: bool = False @field_validator("cover_bonus", mode="after") @classmethod def _check_cover_bonus(cls, value: int) -> int: if value not in _COVER_BONUS_LEVELS: msg = f"cover_bonus must be one of {sorted(_COVER_BONUS_LEVELS)}, got {value}" raise ValueError(msg) return value 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, ...]: """Split an ASCII layer into equal-width rows, failing loudly on gaps. Trailing empty lines are a YAML block-scalar artifact and dropped; an empty line *inside* the layer is a missing row -- a data error, never a silently dropped one. Rows are kept verbatim (no rstrip): trailing spaces are cells whenever " " is in the legend, and the width/char checks catch them otherwise. """ lines = block.splitlines() while lines and lines[-1] == "": lines.pop() if not lines: msg = f"{what} layer is empty" raise MapValidationError(path, msg) if any(line == "" for line in lines): msg = f"{what} layer has a blank row inside the layer" raise MapValidationError(path, msg) if len({len(row) for row in lines}) != 1: msg = f"{what} layer has ragged rows" raise MapValidationError(path, msg) return tuple(lines) 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: if zones is not None: msg = ( "zones layer declared but no deployment section (the README " "requires deployment when zones exist: each zone letter needs " "a side)" ) raise MapValidationError(path, msg) 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) letters = list(deployment.values()) shared = sorted({letter for letter in letters if letters.count(letter) > 1}) if shared: msg = ( "deployment sides share zone letters (each side needs its own " f"distinct zone, or they deploy on top of each other): {shared}" ) 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: # _NoDuplicateLoader subclasses SafeLoader: no arbitrary object # instantiation, it only adds duplicate-key rejection (S506 is a # false positive for this loader). data: object = yaml.load( text, Loader=_NoDuplicateLoader, # noqa: S506 ) 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 if raw.square_size_ft != _SQUARE_SIZE_FT: msg = ( f"square_size_ft must be {_SQUARE_SIZE_FT} (one grid square is 5 " "feet, SRD Measuring Distance); the engine geometry is hard-coded " f"to 5-ft squares, got {raw.square_size_ft}" ) raise MapValidationError(path, msg) terrain = _split_rows(raw.terrain, "terrain", path) height, width = len(terrain), len(terrain[0]) multi_char = sorted(key for key in raw.legend if len(key) != 1) if multi_char: msg = ( "legend keys must be exactly one character (one char = one " f"square), got: {multi_char}" ) raise MapValidationError(path, msg) 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 ]