feat(grid): map YAML schema, 5-10-5 grid, Dijkstra movement, corner LoS/cover

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 6ad37ae9ec
commit 21698d76c6
7 changed files with 780 additions and 0 deletions
+183
View File
@@ -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
]