Files
pf1e-simulator/src/pf1e_simulator/los.py
T
ctan 5bf2850e2f fix(rules): use the attacker's best corner for cover bonus
SRD 'Cover': 'To determine whether your target has cover from your ranged attack, choose a corner of your square.' The ranged scorer in cover_bonus_cells took the MAX over all attacker corners (worst corner); the cover gate has_cover_cells and RAW use the attacker's best corner. Ranged cells are now scored min over attacker corners of (max over target-corner lines), then min over attacker cells - the bonus can no longer exceed what the gate admits. Melee keeps the worst-corner rule ('any line from ANY corner of your square'). Updated the two pins that locked the old worst-corner scores.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 14:57:56 +02:00

327 lines
11 KiB
Python

"""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
_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
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 sets of squares of one grid."""
def __init__(
self,
grid: Grid,
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])
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 (
pos in self._occupied or self._blocking(self._grid.terrain_at(pos))
) and _segment_hits_cell(p1, p2, row, col):
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_bonus(terrain) > 0
def has_line_of_effect(grid: Grid, a: Pos, b: Pos) -> bool:
"""LoE iff some corner of a reaches some corner of b unblocked."""
return has_line_of_effect_cells(grid, frozenset({a}), frozenset({b}))
def has_line_of_effect_cells(
grid: Grid, a_cells: frozenset[Pos], b_cells: frozenset[Pos]
) -> bool:
"""LoE iff some corner of some cell of ``a_cells`` reaches some corner of
some cell of ``b_cells`` unblocked."""
if a_cells & b_cells:
return True
lines = _LineGrid(grid, a_cells | b_cells, lambda terrain: terrain.blocks_los)
return any(
not lines.blocked(corner_a, corner_b)
for cell_a in a_cells
for corner_a in _corners(cell_a)
for cell_b in b_cells
for corner_b in _corners(cell_b)
)
def has_cover(
grid: Grid,
attacker: Pos,
target: Pos,
*,
ranged: bool,
occupied: frozenset[Pos] = frozenset(),
) -> 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. For
ranged attacks, squares occupied by other creatures also block the
lines (soft cover, CRB); melee ignores creatures.
"""
return has_cover_cells(
grid,
frozenset({attacker}),
frozenset({target}),
ranged=ranged,
occupied=occupied,
)
def has_cover_cells(
grid: Grid,
attacker_cells: frozenset[Pos],
target_cells: frozenset[Pos],
*,
ranged: bool,
occupied: frozenset[Pos] = frozenset(),
) -> bool:
"""Cover iff every attacker corner has at least one blocked target corner.
The attacker picks their single best cell (and best corner within it).
Target side: ranged attacks check ANY line to ANY corner of ANY target
cell (union over the target's cells); melee attacks may pick any single
square of a multi-square target (CRB "Big Creatures and Cover"), so
cover exists only when no attacker corner sees every corner of SOME
target cell unblocked. For 1x1 creatures both sides are the single-cell
corner rule.
"""
if attacker_cells & target_cells:
return False
lines = _LineGrid(
grid,
attacker_cells | target_cells,
_grants_cover,
occupied if ranged else frozenset(),
)
if ranged:
return not any(
all(
not lines.blocked(corner_a, corner_t)
for cell_t in target_cells
for corner_t in _corners(cell_t)
)
for cell_a in attacker_cells
for corner_a in _corners(cell_a)
)
return not any(
all(
not lines.blocked(corner_a, corner_t)
for corner_t in _corners(cell_t)
)
for cell_a in attacker_cells
for corner_a in _corners(cell_a)
for cell_t in target_cells
)
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 cover level of the attacker's BEST occupied cell: each
attacker cell is scored by the per-cell corner rule, and the attacker
picks the lowest-scoring (clearest) cell. Target side: ranged attacks are
scored by the maximum over ALL the target's cells; melee attacks may pick
any single square of the target (CRB "Big Creatures and Cover"), so each
attacker cell is scored by the MINIMUM over the target's cells. Interposed
creatures grant soft cover (+4) on ranged attacks only, exactly as in
has_cover_cells. For 1x1 attacker and target this is exactly the
single-cell value.
"""
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,
)
# PF1e CRB "Cover", rule of corners: the attacker chooses the corner (and,
# for a multi-square creature, the occupied square) that gives it the best
# shot, so it suffers only the MINIMUM cover over its own cells. Ranged:
# "choose a corner of your square. If any line from this corner to any
# corner of the target's square passes through cover, the target has
# cover" — each attacker corner is scored by the MAXIMUM over the target's
# corner lines (union over all target cells), and the attacker picks its
# BEST corner: MIN over attacker corners. Melee: the attacker may also
# pick any single square of a multi-square target (CRB "Big Creatures and
# Cover"), so the target side is a MIN over cells; the attacker side
# stays the worst-corner rule ("any line from ANY corner of your
# square").
per_cell_best = (
min(
max(
lines.bonus(corner_a, corner_t)
for corner_a in _corners(cell_a)
for corner_t in _corners(cell_t)
)
for cell_t in target_cells
)
if not ranged
else min(
max(
lines.bonus(corner_a, corner_t)
for cell_t in target_cells
for corner_t in _corners(cell_t)
)
for corner_a in _corners(cell_a)
)
for cell_a in attacker_cells
)
return min(per_cell_best)