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
+4 -5
View File
@@ -115,7 +115,7 @@ if TYPE_CHECKING:
TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled", "nauseated"]
from pf1e_simulator.los import (
has_cover_cells,
cover_bonus_cells,
has_line_of_effect_cells,
)
@@ -123,7 +123,6 @@ _SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
_COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first
_MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments
_MAX_TOUCH_RANGE_INCREMENTS = 5 # PF1e: thrown/firearm touch weapons shoot up to 5 increments
@@ -720,6 +719,7 @@ class CombatEngine:
"move_cost": cell.move_cost,
"blocks_los": cell.blocks_los,
"cover": cell.cover,
"cover_bonus": cell.cover_bonus,
}
for cell in (seen[name] for name in sorted(seen))
)
@@ -1418,14 +1418,13 @@ class CombatEngine:
if s.active and s is not attacker and s is not target
for cell in s.occupied_cells()
)
if has_cover_cells(
ac += cover_bonus_cells(
self._grid,
attacker.occupied_cells(),
target.occupied_cells(),
ranged=self._is_ranged_attack(weapon),
occupied=occupied,
):
ac += _COVER_AC_BONUS
)
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
damage = 0
concealment_miss = False
+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)
)
+12 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
if TYPE_CHECKING:
from pathlib import Path
@@ -20,6 +20,8 @@ Pos = tuple[int, int] # (row, col), 0-indexed
_ELEVATION_CHARS = frozenset("0123456789.") # digit = height, '.' = ground level
_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."""
@@ -38,8 +40,17 @@ class TerrainType(BaseModel):
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."""