feat(combat): support partial and improved cover levels

This commit is contained in:
2026-09-10 11:32:08 +02:00
parent 2eed752f7f
commit e6125b04f6
6 changed files with 401 additions and 23 deletions
+100 -1
View File
@@ -21,6 +21,8 @@ if TYPE_CHECKING:
Pt = tuple[int, int] # lattice point (x, y) = (col, row)
_Rat = tuple[int, int] # exact rational as (numerator, denominator), den > 0
_STANDARD_COVER_BONUS = 4 # PF1e: standard cover, and soft cover from a creature
def _corners(pos: Pos) -> tuple[Pt, Pt, Pt, Pt]:
row, col = pos
@@ -75,11 +77,13 @@ class _LineGrid:
exclude: frozenset[Pos],
blocking: Callable[[TerrainType], bool],
occupied: frozenset[Pos] = frozenset(),
bonus_of: Callable[[TerrainType], int] | None = None,
) -> None:
self._grid = grid
self._exclude = exclude
self._blocking = blocking
self._occupied = occupied
self._bonus_of = bonus_of
def blocked(self, p1: Pt, p2: Pt) -> bool:
min_x = min(p1[0], p2[0])
@@ -97,9 +101,49 @@ class _LineGrid:
return True
return False
def bonus(self, p1: Pt, p2: Pt) -> int:
"""Highest cover bonus among the blocking cells the segment p1->p2 hits.
0 when the segment crosses no blocking cell (or when this view was
built without a bonus function, i.e. for plain line-of-effect).
Occupied cells always grant soft cover (+4, CRB).
"""
if self._bonus_of is None:
return 0
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])
best = 0
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
terrain = self._grid.terrain_at(pos)
is_occupied = pos in self._occupied
if not (is_occupied or self._blocking(terrain)):
continue
if not _segment_hits_cell(p1, p2, row, col):
continue
value = self._bonus_of(terrain)
if is_occupied:
value = max(value, _STANDARD_COVER_BONUS)
best = max(best, value)
return best
def terrain_cover_bonus(terrain: TerrainType) -> int:
"""Cover bonus a terrain square grants: its level, else legacy +4 for cover."""
if terrain.cover_bonus > 0:
return terrain.cover_bonus
if terrain.cover or terrain.blocks_los:
return _STANDARD_COVER_BONUS
return 0
def _grants_cover(terrain: TerrainType) -> bool:
return terrain.cover or terrain.blocks_los
return terrain_cover_bonus(terrain) > 0
def has_line_of_effect(grid: Grid, a: Pos, b: Pos) -> bool:
@@ -179,3 +223,58 @@ def has_cover_cells(
for cell_a in attacker_cells
for corner_a in _corners(cell_a)
)
def cover_bonus(
grid: Grid,
attacker: Pos,
target: Pos,
*,
ranged: bool,
occupied: frozenset[Pos] = frozenset(),
) -> int:
"""Effective cover bonus (0/2/4/8) for an attack between two single cells."""
return cover_bonus_cells(
grid,
frozenset({attacker}),
frozenset({target}),
ranged=ranged,
occupied=occupied,
)
def cover_bonus_cells(
grid: Grid,
attacker_cells: frozenset[Pos],
target_cells: frozenset[Pos],
*,
ranged: bool,
occupied: frozenset[Pos] = frozenset(),
) -> int:
"""Effective cover bonus (0/2/4/8) for an attack between two sets of cells.
0 when there is no cover (see has_cover_cells for the corner rule);
otherwise the highest cover level among the cells blocking the
attacker-corner to target-corner lines. Interposed creatures grant soft
cover (+4) on ranged attacks only, exactly as in has_cover_cells.
"""
if not attacker_cells or not target_cells:
return 0
if not has_cover_cells(
grid, attacker_cells, target_cells, ranged=ranged, occupied=occupied
):
return 0
lines = _LineGrid(
grid,
attacker_cells | target_cells,
_grants_cover,
occupied if ranged else frozenset(),
bonus_of=terrain_cover_bonus,
)
return max(
lines.bonus(corner_a, corner_t)
for cell_a in attacker_cells
for corner_a in _corners(cell_a)
for cell_t in target_cells
for corner_t in _corners(cell_t)
)