282 lines
9.9 KiB
Python
282 lines
9.9 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, 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
|
|
]
|