feat(grid): map YAML schema, 5-10-5 grid, Dijkstra movement, corner LoS/cover
This commit is contained in:
@@ -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
|
||||
]
|
||||
Reference in New Issue
Block a user