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

211 lines
8.4 KiB
Python

"""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)
_AIRBORNE_TERRAIN_COST = 1 # flying is not hampered by ground terrain (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.
An optional elevation layer stores a height per cell; climbing one level
costs one extra square of movement (PF1e), descending is free.
"""
def __init__(
self,
cells: tuple[tuple[TerrainType, ...], ...],
elevation: tuple[tuple[int, ...], ...] | None = None,
) -> None:
self._cells = cells
self._height = len(cells)
self._width = len(cells[0]) if cells else 0
self._elevation = elevation
@staticmethod
def _airborne_terrain(terrain: TerrainType) -> TerrainType:
"""The same square seen from the air (PF1e flight over ground terrain).
SRD "Movement, Position, and Distance" (Terrain and Obstacles):
"Flying and incorporeal creatures are not hampered by difficult
terrain" and "Flying and incorporeal creatures are able to avoid most
obstacles" — so a cell that only slows walking (move_cost > 1) or
stops it (pits, rubble, water: move_cost None) becomes a plain
cost-1 cell in the air view. The exception is a SOLID BARRIER
(`blocks_los`, the engine's encoding of CRB "Aiming a Spell — Line of
Effect": "A line of effect is canceled by a solid barrier"), which is
assumed full height and still stops flight. Cover flags and the
terrain name are preserved; only the walking cost is normalized.
"""
if terrain.blocks_los:
return terrain
cost = terrain.move_cost
if cost is None or cost > _AIRBORNE_TERRAIN_COST:
return terrain.model_copy(update={"move_cost": _AIRBORNE_TERRAIN_COST})
return terrain
def airborne_view(self) -> Grid:
"""A terrain view for airborne creatures (see ``_airborne_terrain``).
Elevation is preserved: the layer stores STEP heights of the ground,
not wall or cliff heights, so the climbing cost still applies to
flight (documented simplification — true 3D flight is not modeled).
"""
cells = tuple(
tuple(self._airborne_terrain(terrain) for terrain in row)
for row in self._cells
)
return Grid(cells, elevation=self._elevation)
@classmethod
def from_spec(cls, spec: MapSpec) -> Grid:
cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain)
elevation = None
if spec.elevation is not None:
height = len(cells)
width = len(cells[0]) if cells else 0
if len(spec.elevation) != height or (
height and len(spec.elevation[0]) != width
):
msg = "elevation layer dimensions do not match terrain"
raise ValueError(msg)
elevation = tuple(
tuple(0 if ch == "." else int(ch) for ch in row)
for row in spec.elevation
)
return cls(cells, elevation=elevation)
@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 elevation_at(self, pos: Pos) -> int:
"""Height of a cell; 0 when no elevation layer or out of bounds."""
if self._elevation is None or not self.in_bounds(pos):
return 0
row, col = pos
return self._elevation[row][col]
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.
Climbing one elevation level adds one square per level; descending is
free and never reduces the terrain cost.
"""
cost = self.terrain_at(to).move_cost
if cost is None:
msg = f"cannot step into impassable square {to}"
raise ValueError(msg)
return self.step_cost_with_terrain(frm, to, diagonals_used, cost)
def step_cost_with_terrain(
self, frm: Pos, to: Pos, diagonals_used: int, cost: int
) -> int:
"""Cost of one step given an explicit destination terrain ``cost``.
The same 5-10-5 + elevation rule as ``step_cost`` (the single home of
that formula), but the caller supplies the terrain cost instead of
reading it from the anchor cell. This lets a multi-cell creature price
a step by the MOST DIFFICULT square its whole footprint would occupy
(SRD "Movement, Position, and Distance": "If you occupy squares with
different kinds of terrain, you can move only as fast as the most
difficult terrain you occupy will allow") rather than by the anchor
square alone.
"""
is_diagonal = frm[0] != to[0] and frm[1] != to[1]
climb = max(0, self.elevation_at(to) - self.elevation_at(frm))
if not is_diagonal:
return cost + climb
if cost >= _DIFFICULT_TERRAIN_COST:
return 3 + climb
return 1 + (diagonals_used % 2) + climb
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 | None, 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.
budget=None disables the cap (full cost field).
"""
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 budget is not None and 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