Files
pf1e-simulator/src/pf1e_simulator/map.py
T

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
]