feat(combat): support partial and improved cover levels
This commit is contained in:
@@ -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
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user