5bf2850e2f
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>
327 lines
11 KiB
Python
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)
|