fix(rules): adjacent melee cover uses the SRD any-line rule (cover.md: 'any line from any corner of your square to the target's square goes through a wall') - the gate previously applied the ranged best-corner rule to distance-1 melee, systematically under-granting cover; non-adjacent melee (reach) keeps the ranged rule, bonus scoring aligned per branch, README 'pire coin' wording updated

This commit is contained in:
2026-09-19 15:35:56 +02:00
parent 9f8655da76
commit 27ce33ba67
4 changed files with 225 additions and 50 deletions
+110 -45
View File
@@ -29,6 +29,22 @@ def _corners(pos: Pos) -> tuple[Pt, Pt, Pt, Pt]:
return ((col, row), (col + 1, row), (col, row + 1), (col + 1, row + 1))
def _adjacent(a: Pos, b: Pos) -> bool:
"""True if the two squares touch (share an edge or a corner)."""
return max(abs(a[0] - b[0]), abs(a[1] - b[1])) == 1
def _melee_is_adjacent(
attacker_cells: frozenset[Pos], target_cells: frozenset[Pos]
) -> bool:
"""True if some attacker square touches some target square."""
return any(
_adjacent(cell_a, cell_t)
for cell_a in attacker_cells
for cell_t in target_cells
)
def _norm(num: int, den: int) -> _Rat:
return (num, den) if den > 0 else (-num, -den)
@@ -176,12 +192,13 @@ def has_cover(
ranged: bool,
occupied: frozenset[Pos] = frozenset(),
) -> bool:
"""Cover iff every attacker corner has at least one blocked target corner.
"""Cover with the SRD corner rule (see has_cover_cells).
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.
Ranged (and non-adjacent melee): the attacker picks their single best
corner (PF1e ranged cover rule). Adjacent melee: the SRD any-line rule
(cover.md: "any line from any corner of your square to the target's
square"). For ranged attacks, squares occupied by other creatures also
block the lines (soft cover, CRB); melee ignores creatures.
"""
return has_cover_cells(
grid,
@@ -200,15 +217,22 @@ def has_cover_cells(
ranged: bool,
occupied: frozenset[Pos] = frozenset(),
) -> bool:
"""Cover iff every attacker corner has at least one blocked target corner.
"""Cover for an attack between two sets of cells, per the SRD corner rules.
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.
Ranged (and non-adjacent melee, which cover.md sends to the ranged
rule): the attacker picks their single best cell (and best corner
within it), so cover exists only when every attacker corner has at
least one blocked target corner. Adjacent melee (some attacker square
touching some target square): cover.md grants cover if ANY line from
ANY corner of the attacker's square to the target's square goes
through a wall, so cover exists as soon as one corner line is blocked.
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. For ranged attacks, squares occupied
by other creatures block the lines (soft cover); melee ignores them.
"""
if attacker_cells & target_cells:
return False
@@ -228,6 +252,27 @@ def has_cover_cells(
for cell_a in attacker_cells
for corner_a in _corners(cell_a)
)
if _melee_is_adjacent(attacker_cells, target_cells):
# SRD cover.md, adjacent melee: "your target has cover if any line
# from any corner of your square to the target's square goes
# through a wall". One blocked corner line is enough. With a
# multi-square attacker the defender still picks the pair it
# attacks through (CRB "Big Creatures and Cover" applies to both
# sides), so cover exists only when EVERY touching attacker /
# target square pair has at least one blocked corner line.
return all(
any(
lines.blocked(corner_a, corner_t)
for corner_a in _corners(cell_a)
for corner_t in _corners(cell_t)
)
for cell_a in attacker_cells
for cell_t in target_cells
if _adjacent(cell_a, cell_t)
)
# Non-adjacent melee (reach weapons): cover.md sends these to the
# ranged rule, but the attacker may still pick any single square of a
# multi-square target (CRB "Big Creatures and Cover").
return not any(
all(
not lines.blocked(corner_a, corner_t)
@@ -268,15 +313,15 @@ def cover_bonus_cells(
"""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.
otherwise the cover level scored under the same rule the gate uses:
ranged and non-adjacent melee score each attacker corner by the
maximum over the target-corner lines and the attacker keeps its BEST
corner (and best own cell); adjacent melee scores each touching
attacker/target square pair by the maximum over ALL its corner lines
(any blocked line grants cover) and the attacker keeps its best pair
of 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
@@ -291,36 +336,56 @@ def cover_bonus_cells(
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(
# PF1e CRB "Cover", rule of corners. 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, then MIN over its own cells
# (B20). Adjacent melee: "any line from ANY corner of your square to
# the target's square" - within a chosen square pair the level is the
# MAXIMUM over all sixteen corner lines, and the attacker picks its
# best pair: MIN over cells on both sides. Non-adjacent melee
# (reach): cover.md sends the attack to the ranged rule, but the
# attacker may pick any single square of a multi-square target (CRB
# "Big Creatures and Cover"), so the target side is a MIN over cells
# while the attacker side stays the best-corner MIN.
adjacent_melee = not ranged and _melee_is_adjacent(attacker_cells, target_cells)
if adjacent_melee:
return min(
max(
lines.bonus(corner_a, corner_t)
for corner_a in _corners(cell_a)
for corner_t in _corners(cell_t)
)
for cell_a in attacker_cells
for cell_t in target_cells
if _adjacent(cell_a, cell_t)
)
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)
if ranged:
per_cell_best = (
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 corner_a in _corners(cell_a)
for cell_a in attacker_cells
)
else:
per_cell_best = (
min(
min(
max(
lines.bonus(corner_a, corner_t)
for corner_t in _corners(cell_t)
)
for corner_a in _corners(cell_a)
)
for cell_t in target_cells
)
for cell_a in attacker_cells
)
for cell_a in attacker_cells
)
return min(per_cell_best)