feat(grid): map YAML schema, 5-10-5 grid, Dijkstra movement, corner LoS/cover
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
name: "Arène d'essai"
|
||||||
|
square_size_ft: 5
|
||||||
|
|
||||||
|
terrain: |
|
||||||
|
####################
|
||||||
|
#......######......#
|
||||||
|
#..C...######...C..#
|
||||||
|
#......~..~........#
|
||||||
|
#..TT..~..~...TT...#
|
||||||
|
#......~..~........#
|
||||||
|
#..C............C..#
|
||||||
|
####################
|
||||||
|
|
||||||
|
legend:
|
||||||
|
"#": { type: wall, move_cost: null, blocks_los: true }
|
||||||
|
".": { type: floor, move_cost: 1 }
|
||||||
|
"T": { type: rubble, move_cost: 2 }
|
||||||
|
"~": { type: water, move_cost: 2 }
|
||||||
|
"C": { type: pillar, move_cost: null, cover: true }
|
||||||
|
|
||||||
|
elevation: |
|
||||||
|
....................
|
||||||
|
..1111..............
|
||||||
|
..1111..............
|
||||||
|
....................
|
||||||
|
....................
|
||||||
|
....................
|
||||||
|
....................
|
||||||
|
....................
|
||||||
|
|
||||||
|
zones: |
|
||||||
|
....................
|
||||||
|
.AAAA..........BBBB.
|
||||||
|
.AAAA..........BBBB.
|
||||||
|
....................
|
||||||
|
....................
|
||||||
|
....................
|
||||||
|
....................
|
||||||
|
....................
|
||||||
|
|
||||||
|
markers:
|
||||||
|
autel: [1, 16]
|
||||||
|
brasier_ouest: [6, 5]
|
||||||
|
brasier_est: [6, 14]
|
||||||
|
|
||||||
|
deployment:
|
||||||
|
players: A
|
||||||
|
monsters: B
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Square grid: PF1e distances, movement costs, and Dijkstra pathing.
|
||||||
|
|
||||||
|
Diagonal movement alternates 1-2-1-2 squares (the 5-10-5-10 ft rule), so the
|
||||||
|
Dijkstra state carries the parity of diagonals used. A diagonal step into
|
||||||
|
difficult terrain costs a flat 3 squares and still counts as a diagonal for
|
||||||
|
the alternation (table convention). A diagonal step requires both flanking
|
||||||
|
orthogonal squares to be terrain-passable (no cutting wall corners); creature
|
||||||
|
occupancy never blocks a diagonal, and occupied squares cannot be entered in
|
||||||
|
Phase 0 (moving through allies is a later refinement).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import heapq
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pf1e_simulator.map import MapSpec, Pos, TerrainType
|
||||||
|
|
||||||
|
_DIFFICULT_TERRAIN_COST = 2 # rubble, water, etc. count as 2 squares (PF1e)
|
||||||
|
|
||||||
|
_DIRECTIONS: tuple[Pos, ...] = (
|
||||||
|
(-1, -1),
|
||||||
|
(-1, 0),
|
||||||
|
(-1, 1),
|
||||||
|
(0, -1),
|
||||||
|
(0, 1),
|
||||||
|
(1, -1),
|
||||||
|
(1, 0),
|
||||||
|
(1, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Grid:
|
||||||
|
"""Terrain view of a MapSpec with movement and distance queries."""
|
||||||
|
|
||||||
|
def __init__(self, cells: tuple[tuple[TerrainType, ...], ...]) -> None:
|
||||||
|
self._cells = cells
|
||||||
|
self._height = len(cells)
|
||||||
|
self._width = len(cells[0]) if cells else 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_spec(cls, spec: MapSpec) -> Grid:
|
||||||
|
cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain)
|
||||||
|
return cls(cells)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> int:
|
||||||
|
return self._height
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> int:
|
||||||
|
return self._width
|
||||||
|
|
||||||
|
def in_bounds(self, pos: Pos) -> bool:
|
||||||
|
row, col = pos
|
||||||
|
return 0 <= row < self._height and 0 <= col < self._width
|
||||||
|
|
||||||
|
def terrain_at(self, pos: Pos) -> TerrainType:
|
||||||
|
row, col = pos
|
||||||
|
return self._cells[row][col]
|
||||||
|
|
||||||
|
def passable(self, pos: Pos) -> bool:
|
||||||
|
return self.in_bounds(pos) and self.terrain_at(pos).move_cost is not None
|
||||||
|
|
||||||
|
def distance(self, a: Pos, b: Pos) -> int:
|
||||||
|
"""Distance in squares under the 5-10-5-10 diagonal rule."""
|
||||||
|
d_row = abs(a[0] - b[0])
|
||||||
|
d_col = abs(a[1] - b[1])
|
||||||
|
return max(d_row, d_col) + min(d_row, d_col) // 2
|
||||||
|
|
||||||
|
def step_cost(self, frm: Pos, to: Pos, diagonals_used: int) -> int:
|
||||||
|
"""Cost in squares of one step; diagonals_used is the count so far."""
|
||||||
|
cost = self.terrain_at(to).move_cost
|
||||||
|
if cost is None:
|
||||||
|
msg = f"cannot step into impassable square {to}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
is_diagonal = frm[0] != to[0] and frm[1] != to[1]
|
||||||
|
if not is_diagonal:
|
||||||
|
return cost
|
||||||
|
if cost >= _DIFFICULT_TERRAIN_COST:
|
||||||
|
return 3
|
||||||
|
return 1 + (diagonals_used % 2)
|
||||||
|
|
||||||
|
def diagonal_allowed(self, frm: Pos, to: Pos) -> bool:
|
||||||
|
flank_a = (frm[0], to[1])
|
||||||
|
flank_b = (to[0], frm[1])
|
||||||
|
return self.passable(flank_a) and self.passable(flank_b)
|
||||||
|
|
||||||
|
def reachable(self, start: Pos, budget: int, blocked: frozenset[Pos]) -> dict[Pos, int]:
|
||||||
|
"""Cheapest movement cost per reachable square, capped at budget.
|
||||||
|
|
||||||
|
`blocked` holds creature-occupied squares: unenterable, but never
|
||||||
|
affecting diagonal_allowed. The start square is included at cost 0.
|
||||||
|
"""
|
||||||
|
best_per_state: dict[tuple[Pos, int], int] = {(start, 0): 0}
|
||||||
|
results: dict[Pos, int] = {start: 0}
|
||||||
|
heap: list[tuple[int, Pos, int]] = [(0, start, 0)]
|
||||||
|
while heap:
|
||||||
|
cost, pos, parity = heapq.heappop(heap)
|
||||||
|
if cost > best_per_state.get((pos, parity), cost):
|
||||||
|
continue
|
||||||
|
row, col = pos
|
||||||
|
for d_row, d_col in _DIRECTIONS:
|
||||||
|
nxt = (row + d_row, col + d_col)
|
||||||
|
if nxt == start or nxt in blocked or not self.passable(nxt):
|
||||||
|
continue
|
||||||
|
is_diagonal = d_row != 0 and d_col != 0
|
||||||
|
if is_diagonal and not self.diagonal_allowed(pos, nxt):
|
||||||
|
continue
|
||||||
|
new_cost = cost + self.step_cost(pos, nxt, parity)
|
||||||
|
if new_cost > budget:
|
||||||
|
continue
|
||||||
|
new_parity = (parity + 1) % 2 if is_diagonal else parity
|
||||||
|
state = (nxt, new_parity)
|
||||||
|
known = best_per_state.get(state)
|
||||||
|
if known is None or new_cost < known:
|
||||||
|
best_per_state[state] = new_cost
|
||||||
|
current = results.get(nxt)
|
||||||
|
if current is None or new_cost < current:
|
||||||
|
results[nxt] = new_cost
|
||||||
|
heapq.heappush(heap, (new_cost, nxt, new_parity))
|
||||||
|
return results
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Corner-rule line of effect and cover.
|
||||||
|
|
||||||
|
Squares are unit cells on an integer lattice: cell (row, col) spans
|
||||||
|
x in [col, col+1], y in [row, row+1]. A line is blocked when its RELATIVE
|
||||||
|
INTERIOR touches a blocking square's closure (interior, border, or vertex);
|
||||||
|
lines merely starting or ending on a blocking vertex do not count. All math
|
||||||
|
is exact integer arithmetic: parameter t is kept as (num, den) pairs, no
|
||||||
|
floats anywhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.map import Pos, TerrainType
|
||||||
|
|
||||||
|
Pt = tuple[int, int] # lattice point (x, y) = (col, row)
|
||||||
|
_Rat = tuple[int, int] # exact rational as (numerator, denominator), den > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _corners(pos: Pos) -> tuple[Pt, Pt, Pt, Pt]:
|
||||||
|
row, col = pos
|
||||||
|
return ((col, row), (col + 1, row), (col, row + 1), (col + 1, row + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(num: int, den: int) -> _Rat:
|
||||||
|
return (num, den) if den > 0 else (-num, -den)
|
||||||
|
|
||||||
|
|
||||||
|
def _le(a: _Rat, b: _Rat) -> bool:
|
||||||
|
return a[0] * b[1] <= b[0] * a[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _lt(a: _Rat, b: _Rat) -> bool:
|
||||||
|
return a[0] * b[1] < b[0] * a[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _slab(p1: Pt, delta: Pt, lo: int, hi: int, axis: int) -> tuple[_Rat, _Rat] | None:
|
||||||
|
"""Closed t-interval where p1 + t*delta lies within [lo, hi] on `axis`."""
|
||||||
|
start = p1[axis]
|
||||||
|
step = delta[axis]
|
||||||
|
if step == 0:
|
||||||
|
return ((0, 1), (1, 1)) if lo <= start <= hi else None
|
||||||
|
t1 = _norm(lo - start, step)
|
||||||
|
t2 = _norm(hi - start, step)
|
||||||
|
return (t1, t2) if _le(t1, t2) else (t2, t1)
|
||||||
|
|
||||||
|
|
||||||
|
def _segment_hits_cell(p1: Pt, p2: Pt, row: int, col: int) -> bool:
|
||||||
|
"""True iff the relative interior of p1->p2 touches the closed cell."""
|
||||||
|
delta = (p2[0] - p1[0], p2[1] - p1[1])
|
||||||
|
if delta == (0, 0):
|
||||||
|
return False
|
||||||
|
interval_x = _slab(p1, delta, col, col + 1, 0)
|
||||||
|
if interval_x is None:
|
||||||
|
return False
|
||||||
|
interval_y = _slab(p1, delta, row, row + 1, 1)
|
||||||
|
if interval_y is None:
|
||||||
|
return False
|
||||||
|
t_low = interval_x[0] if _le(interval_y[0], interval_x[0]) else interval_y[0]
|
||||||
|
t_high = interval_x[1] if _le(interval_x[1], interval_y[1]) else interval_y[1]
|
||||||
|
return _le(t_low, t_high) and _lt((0, 1), t_high) and _lt(t_low, (1, 1))
|
||||||
|
|
||||||
|
|
||||||
|
class _LineGrid:
|
||||||
|
"""Bound view for corner-line checks between two squares of one grid."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
grid: Grid,
|
||||||
|
a: Pos,
|
||||||
|
b: Pos,
|
||||||
|
blocking: Callable[[TerrainType], bool],
|
||||||
|
) -> None:
|
||||||
|
self._grid = grid
|
||||||
|
self._exclude = frozenset({a, b})
|
||||||
|
self._blocking = blocking
|
||||||
|
|
||||||
|
def blocked(self, p1: Pt, p2: Pt) -> bool:
|
||||||
|
min_x = min(p1[0], p2[0])
|
||||||
|
max_x = max(p1[0], p2[0])
|
||||||
|
min_y = min(p1[1], p2[1])
|
||||||
|
max_y = max(p1[1], p2[1])
|
||||||
|
for col in range(min_x - 1, max_x + 1):
|
||||||
|
for row in range(min_y - 1, max_y + 1):
|
||||||
|
pos = (row, col)
|
||||||
|
if pos in self._exclude or not self._grid.in_bounds(pos):
|
||||||
|
continue
|
||||||
|
if self._blocking(self._grid.terrain_at(pos)) and _segment_hits_cell(
|
||||||
|
p1, p2, row, col
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _grants_cover(terrain: TerrainType) -> bool:
|
||||||
|
return terrain.cover or terrain.blocks_los
|
||||||
|
|
||||||
|
|
||||||
|
def has_line_of_effect(grid: Grid, a: Pos, b: Pos) -> bool:
|
||||||
|
"""LoE iff some corner of a reaches some corner of b unblocked."""
|
||||||
|
if a == b:
|
||||||
|
return True
|
||||||
|
lines = _LineGrid(grid, a, b, lambda terrain: terrain.blocks_los)
|
||||||
|
return any(
|
||||||
|
not lines.blocked(corner_a, corner_b)
|
||||||
|
for corner_a in _corners(a)
|
||||||
|
for corner_b in _corners(b)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_cover(grid: Grid, attacker: Pos, target: Pos, *, ranged: bool) -> bool:
|
||||||
|
"""Cover iff every attacker corner has at least one blocked target corner.
|
||||||
|
|
||||||
|
The attacker picks their single best corner (PF1e ranged cover rule);
|
||||||
|
Phase 0 documented deviation: melee reuses the same corner rule.
|
||||||
|
"""
|
||||||
|
if attacker == target:
|
||||||
|
return False
|
||||||
|
_ = ranged
|
||||||
|
lines = _LineGrid(grid, attacker, target, _grants_cover)
|
||||||
|
return not any(
|
||||||
|
all(not lines.blocked(corner_a, corner_t) for corner_t in _corners(target))
|
||||||
|
for corner_a in _corners(attacker)
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Tests for the square grid: distance, step costs, corner rule, Dijkstra."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
|
||||||
|
|
||||||
|
def make_grid(rows: list[str]) -> Grid:
|
||||||
|
legend = {
|
||||||
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||||
|
".": TerrainType(type="floor", move_cost=1),
|
||||||
|
"T": TerrainType(type="rubble", move_cost=2),
|
||||||
|
"C": TerrainType(type="pillar", move_cost=None, cover=True),
|
||||||
|
}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(rows), legend=legend)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distance_diagonal_table() -> None:
|
||||||
|
grid = make_grid([".....", ".....", ".....", ".....", "....."])
|
||||||
|
assert grid.distance((0, 0), (1, 1)) == 1
|
||||||
|
assert grid.distance((0, 0), (2, 2)) == 3
|
||||||
|
assert grid.distance((0, 0), (3, 2)) == 4
|
||||||
|
assert grid.distance((0, 0), (0, 3)) == 3
|
||||||
|
assert grid.distance((2, 1), (0, 0)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_cost_orthogonal_uses_entered_square() -> None:
|
||||||
|
grid = make_grid([".T."])
|
||||||
|
assert grid.step_cost((0, 0), (0, 1), 0) == 2
|
||||||
|
assert grid.step_cost((0, 1), (0, 2), 0) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_cost_diagonal_alternates() -> None:
|
||||||
|
grid = make_grid(["...", "...", "..."])
|
||||||
|
assert grid.step_cost((0, 0), (1, 1), 0) == 1
|
||||||
|
assert grid.step_cost((1, 1), (2, 2), 1) == 2
|
||||||
|
assert grid.step_cost((1, 1), (2, 2), 2) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_cost_diagonal_into_difficult_is_flat_three() -> None:
|
||||||
|
grid = make_grid(["..", ".T"])
|
||||||
|
assert grid.step_cost((0, 0), (1, 1), 0) == 3
|
||||||
|
assert grid.step_cost((0, 0), (1, 1), 1) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagonal_forbidden_past_wall_corner() -> None:
|
||||||
|
grid = make_grid([
|
||||||
|
".#.",
|
||||||
|
".#.",
|
||||||
|
"...",
|
||||||
|
])
|
||||||
|
assert grid.diagonal_allowed((2, 1), (1, 0)) is False
|
||||||
|
assert grid.diagonal_allowed((2, 1), (1, 2)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagonal_allowed_with_both_flanks_clear() -> None:
|
||||||
|
grid = make_grid([
|
||||||
|
".#.",
|
||||||
|
"...",
|
||||||
|
"...",
|
||||||
|
])
|
||||||
|
assert grid.diagonal_allowed((2, 1), (1, 0)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_reachable_open_floor_costs() -> None:
|
||||||
|
grid = make_grid([".....", ".....", ".....", ".....", "....."])
|
||||||
|
reached = grid.reachable((0, 0), 10, frozenset())
|
||||||
|
assert reached[(0, 0)] == 0
|
||||||
|
assert reached[(1, 1)] == 1
|
||||||
|
assert reached[(2, 2)] == 3
|
||||||
|
assert reached[(3, 3)] == 4
|
||||||
|
assert reached[(4, 4)] == 6
|
||||||
|
assert reached[(0, 4)] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_reachable_respects_budget() -> None:
|
||||||
|
grid = make_grid([".....", ".....", ".....", ".....", "....."])
|
||||||
|
reached = grid.reachable((0, 0), 2, frozenset())
|
||||||
|
assert (1, 1) in reached
|
||||||
|
assert (2, 2) not in reached
|
||||||
|
|
||||||
|
|
||||||
|
def test_reachable_diagonal_into_rubble_costs_three() -> None:
|
||||||
|
grid = make_grid(["...", ".T.", "..."])
|
||||||
|
reached = grid.reachable((0, 0), 6, frozenset())
|
||||||
|
assert reached[(1, 1)] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_reachable_through_rubble_cheaper_than_around() -> None:
|
||||||
|
grid = make_grid(["...", ".T.", "..."])
|
||||||
|
reached = grid.reachable((1, 0), 6, frozenset())
|
||||||
|
assert reached[(1, 2)] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_reachable_creatures_block_entry_not_diagonals() -> None:
|
||||||
|
grid = make_grid(["...", "...", "..."])
|
||||||
|
reached = grid.reachable((1, 1), 1, frozenset({(1, 0)}))
|
||||||
|
assert (1, 0) not in reached
|
||||||
|
assert reached[(0, 2)] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reachable_paths_around_creatures() -> None:
|
||||||
|
grid = make_grid(["...", ".#.", "..."])
|
||||||
|
reached = grid.reachable((1, 0), 6, frozenset())
|
||||||
|
assert (1, 1) not in reached
|
||||||
|
assert reached[(1, 2)] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_reachable_cannot_enter_wall_squares() -> None:
|
||||||
|
grid = make_grid(["..", ".C"])
|
||||||
|
reached = grid.reachable((0, 0), 9, frozenset())
|
||||||
|
assert (1, 1) not in reached
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Tests for corner-rule line of effect and cover (los.py).
|
||||||
|
|
||||||
|
Geometry convention: a line is blocked when its relative interior touches a
|
||||||
|
blocking square (interior, border, or vertex); lines that merely start or end
|
||||||
|
on a blocking square's vertex do not count as passing through it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.los import has_cover, has_line_of_effect
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
|
||||||
|
|
||||||
|
def make_grid(rows: list[str]) -> Grid:
|
||||||
|
legend = {
|
||||||
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||||
|
".": TerrainType(type="floor", move_cost=1),
|
||||||
|
"T": TerrainType(type="rubble", move_cost=2),
|
||||||
|
"C": TerrainType(type="pillar", move_cost=None, cover=True),
|
||||||
|
}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(rows), legend=legend)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def test_los_open_field() -> None:
|
||||||
|
grid = make_grid(["...", "...", "..."])
|
||||||
|
assert has_line_of_effect(grid, (0, 0), (2, 2)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_los_same_square() -> None:
|
||||||
|
grid = make_grid(["."])
|
||||||
|
assert has_line_of_effect(grid, (0, 0), (0, 0)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_los_blocked_by_wall_between() -> None:
|
||||||
|
grid = make_grid([".#.", "..."])
|
||||||
|
assert has_line_of_effect(grid, (0, 0), (0, 2)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_los_through_doorway_gap() -> None:
|
||||||
|
grid = make_grid(["##.##", "##.##"])
|
||||||
|
assert has_line_of_effect(grid, (0, 2), (1, 2)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_los_pinched_vertex_stays_open() -> None:
|
||||||
|
grid = make_grid([".#.", "#.."])
|
||||||
|
assert has_line_of_effect(grid, (0, 0), (1, 1)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_los_blocked_past_wall_line() -> None:
|
||||||
|
grid = make_grid(["...#.", "....."])
|
||||||
|
assert has_line_of_effect(grid, (0, 0), (0, 4)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_open_field() -> None:
|
||||||
|
grid = make_grid([".....", "....."])
|
||||||
|
assert has_cover(grid, (0, 0), (0, 4), ranged=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_pillar_between() -> None:
|
||||||
|
grid = make_grid([".....", "..C..", "....."])
|
||||||
|
assert has_cover(grid, (1, 0), (1, 4), ranged=True) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_cover_when_pillar_off_axis() -> None:
|
||||||
|
grid = make_grid(["..C..", "....."])
|
||||||
|
assert has_cover(grid, (1, 0), (1, 4), ranged=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_cover_adjacent_with_wall_behind_target() -> None:
|
||||||
|
grid = make_grid(["..#"])
|
||||||
|
assert has_cover(grid, (0, 0), (0, 1), ranged=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_diagonal_past_wall_vertex() -> None:
|
||||||
|
grid = make_grid([".#.", "#.."])
|
||||||
|
assert has_cover(grid, (0, 0), (1, 1), ranged=True) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_melee_uses_same_corner_rule_in_phase0() -> None:
|
||||||
|
grid = make_grid([".#.", "#.."])
|
||||||
|
assert has_cover(grid, (0, 0), (1, 1), ranged=False) is True
|
||||||
|
open_grid = make_grid(["...", "..."])
|
||||||
|
assert has_cover(open_grid, (0, 0), (1, 1), ranged=False) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_same_square_is_false() -> None:
|
||||||
|
grid = make_grid(["."])
|
||||||
|
assert has_cover(grid, (0, 0), (0, 0), ranged=True) is False
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Tests for the map YAML schema (map.py)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pf1e_simulator.map import MapValidationError, load_map, zone_cells
|
||||||
|
|
||||||
|
MAPS_DIR = Path(__file__).resolve().parents[1] / "data" / "maps"
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path: Path, body: str) -> Path:
|
||||||
|
target = tmp_path / "map.yaml"
|
||||||
|
target.write_text(body, encoding="utf-8")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_map() -> str:
|
||||||
|
return """
|
||||||
|
name: "Test"
|
||||||
|
terrain: |
|
||||||
|
###
|
||||||
|
#.#
|
||||||
|
###
|
||||||
|
legend:
|
||||||
|
"#": { type: wall, blocks_los: true }
|
||||||
|
".": { type: floor, move_cost: 1 }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_arena_loads() -> None:
|
||||||
|
spec = load_map(MAPS_DIR / "sample_arena.yaml")
|
||||||
|
assert spec.square_size_ft == 5
|
||||||
|
assert len(spec.terrain) == 8
|
||||||
|
assert all(len(row) == 20 for row in spec.terrain)
|
||||||
|
assert spec.legend["#"].move_cost is None
|
||||||
|
assert spec.legend["#"].blocks_los is True
|
||||||
|
assert spec.legend["C"].cover is True
|
||||||
|
assert spec.legend["T"].move_cost == 2
|
||||||
|
assert spec.deployment == {"players": "A", "monsters": "B"}
|
||||||
|
assert spec.markers["autel"] == (1, 16)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_arena_zones_and_elevation() -> None:
|
||||||
|
spec = load_map(MAPS_DIR / "sample_arena.yaml")
|
||||||
|
assert spec.elevation is not None
|
||||||
|
assert spec.elevation[1][2] == "1"
|
||||||
|
cells_a = zone_cells(spec, "A")
|
||||||
|
assert cells_a == [
|
||||||
|
(1, 1), (1, 2), (1, 3), (1, 4),
|
||||||
|
(2, 1), (2, 2), (2, 3), (2, 4),
|
||||||
|
]
|
||||||
|
assert len(zone_cells(spec, "B")) == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimal_map_without_optional_layers(tmp_path: Path) -> None:
|
||||||
|
spec = load_map(_write(tmp_path, _minimal_map()))
|
||||||
|
assert spec.elevation is None
|
||||||
|
assert spec.zones is None
|
||||||
|
assert spec.markers == {}
|
||||||
|
assert zone_cells(spec, "A") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_ragged_rows(tmp_path: Path) -> None:
|
||||||
|
body = _minimal_map().replace(" #.#\n", " #.##\n")
|
||||||
|
with pytest.raises(MapValidationError):
|
||||||
|
load_map(_write(tmp_path, body))
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_char_missing_from_legend(tmp_path: Path) -> None:
|
||||||
|
body = _minimal_map().replace(" #.#\n", " #x#\n")
|
||||||
|
with pytest.raises(MapValidationError):
|
||||||
|
load_map(_write(tmp_path, body))
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_elevation_dimension_mismatch(tmp_path: Path) -> None:
|
||||||
|
body = _minimal_map() + "elevation: |\n 000\n 00\n"
|
||||||
|
with pytest.raises(MapValidationError):
|
||||||
|
load_map(_write(tmp_path, body))
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_marker_out_of_bounds(tmp_path: Path) -> None:
|
||||||
|
body = _minimal_map() + "markers:\n autel: [9, 1]\n"
|
||||||
|
with pytest.raises(MapValidationError):
|
||||||
|
load_map(_write(tmp_path, body))
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_deployment_without_zone(tmp_path: Path) -> None:
|
||||||
|
body = _minimal_map() + "zones: |\n ...\n .A.\n ...\ndeployment:\n players: B\n"
|
||||||
|
with pytest.raises(MapValidationError):
|
||||||
|
load_map(_write(tmp_path, body))
|
||||||
Reference in New Issue
Block a user