184 lines
6.0 KiB
Python
184 lines
6.0 KiB
Python
"""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
|
|
]
|