fix(rules): pick the best target square for melee cover

PF1e (CRB Cover, Big Creatures and Cover): when making a melee attack
against a multi-square creature you can pick any of the squares it
occupies to determine cover. has_cover_cells/cover_bonus_cells now
aggregate the target side by the MINIMUM over target cells for melee and
keep the maximum (any line to any corner) for ranged; the attacker-side
minimum is unchanged. 1x1 geometry is bit-identical; no data case changes
today, locked by a unit test (Large target: melee 0, ranged +4).

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-11 23:29:59 +02:00
parent 843cbf881c
commit 8a8b813889
3 changed files with 70 additions and 14 deletions
+8 -3
View File
@@ -650,9 +650,14 @@ Règles modélisées :
attaques de mêlée ignorent les créatures). Conformance B20 (CRB « Cover », attaques de mêlée ignorent les créatures). Conformance B20 (CRB « Cover »,
règle des coins) : c'est **l'attaquant qui choisit son meilleur coin** — règle des coins) : c'est **l'attaquant qui choisit son meilleur coin** —
une créature multi-cases (Large +) ne subit que le couvert de SA meilleure une créature multi-cases (Large +) ne subit que le couvert de SA meilleure
case occupée (minimum sur ses cases), pas le pire. Le côté cible reste case occupée (minimum sur ses cases), pas le pire. Côté cible : à distance,
évalué par la règle RAW : toute ligne vers tout coin des cases de la cible toute ligne vers tout coin des cases de la cible compte (maximum sur les
compte (maximum sur les coins de la cible). coins de la cible) ; **en mêlée**, l'attaquant peut choisir n'importe
quelle case occupée par une créature multi-cases pour déterminer le couvert
(conformance A5 — CRB « Big Creatures and Cover » : « when making a melee
attack against such a creature, you can pick any of the squares it occupies
to determine if it has cover against you ») — le score est donc le minimum
sur les cases de la cible en mêlée, le maximum à distance.
- Flanquement : +2 au jet d'attaque de mêlée (toucher de mêlée inclus, voir - Flanquement : +2 au jet d'attaque de mêlée (toucher de mêlée inclus, voir
« Touch AC ») si un allié actif menace réellement la cible depuis la « Touch AC ») si un allié actif menace réellement la cible depuis la
bordure ou le coin opposé — conformance P2 : l'allié doit **menacer** une bordure ou le coin opposé — conformance P2 : l'allié doit **menacer** une
+36 -9
View File
@@ -202,9 +202,13 @@ def has_cover_cells(
) -> bool: ) -> bool:
"""Cover iff every attacker corner has at least one blocked target corner. """Cover iff every attacker corner has at least one blocked target corner.
The attacker picks their single best cell (and best corner within it); The attacker picks their single best cell (and best corner within it).
the target's corners are the union over all its cells. For 1x1 creatures Target side: ranged attacks check ANY line to ANY corner of ANY target
this is exactly the single-cell corner rule. 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: if attacker_cells & target_cells:
return False return False
@@ -214,6 +218,7 @@ def has_cover_cells(
_grants_cover, _grants_cover,
occupied if ranged else frozenset(), occupied if ranged else frozenset(),
) )
if ranged:
return not any( return not any(
all( all(
not lines.blocked(corner_a, corner_t) not lines.blocked(corner_a, corner_t)
@@ -223,6 +228,15 @@ def has_cover_cells(
for cell_a in attacker_cells for cell_a in attacker_cells
for corner_a in _corners(cell_a) 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( def cover_bonus(
@@ -255,10 +269,13 @@ def cover_bonus_cells(
0 when there is no cover (see has_cover_cells for the corner rule); 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 otherwise the cover level of the attacker's BEST occupied cell: each
attacker cell is scored by the per-cell corner rule against all of the attacker cell is scored by the per-cell corner rule, and the attacker
target's cells, and the attacker picks the lowest-scoring (clearest) picks the lowest-scoring (clearest) cell. Target side: ranged attacks are
cell. Interposed creatures grant soft cover (+4) on ranged attacks only, scored by the maximum over ALL the target's cells; melee attacks may pick
exactly as in has_cover_cells. For a 1x1 attacker this is exactly the 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. single-cell value.
""" """
if not attacker_cells or not target_cells: if not attacker_cells or not target_cells:
@@ -277,10 +294,20 @@ def cover_bonus_cells(
# PF1e CRB "Cover", rule of corners: the attacker chooses the corner (and, # 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 # 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. The # shot, so it suffers only the MINIMUM cover over its own cells. The
# target side is unchanged: RAW checks ANY line to ANY target corner, so # target side follows the same choice rule: RAW checks ANY line to ANY
# each attacker cell is scored by the MAXIMUM over its corner lines. # corner of the (chosen) square, scored by the MAXIMUM over corner lines —
# union over all target cells for ranged, best single cell for melee.
per_cell_best = ( per_cell_best = (
min(
max( 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 max(
lines.bonus(corner_a, corner_t) lines.bonus(corner_a, corner_t)
for corner_a in _corners(cell_a) for corner_a in _corners(cell_a)
for cell_t in target_cells for cell_t in target_cells
+24
View File
@@ -150,3 +150,27 @@ def test_ranged_touch_attack_still_gets_cover() -> None:
# Touch AC 13 + 4 standard cover: cover is NOT removed for touch. # Touch AC 13 + 4 standard cover: cover is NOT removed for touch.
assert result.ac == 17 assert result.ac == 17
assert result.hit is False # 12 + 4 = 16 < 17 assert result.hit is False # 12 + 4 = 16 < 17
# ---------------------------------------------------------------------------
# (f) A5: melee vs a multi-square target picks the BEST target square
# ---------------------------------------------------------------------------
def test_melee_vs_large_target_picks_best_target_square() -> None:
grid = _grid(("..#...", "......", "......"))
attacker = frozenset({(1, 0)})
large_target = frozenset({(0, 3), (0, 4), (1, 3), (1, 4)})
# Ranged: RAW checks ANY line to ANY target corner — the wall covers the
# target's top row, so the worst cell's +4 stands.
assert cover_bonus_cells(grid, attacker, large_target, ranged=True) == 4
# Melee (CRB "Big Creatures and Cover"): "when making a melee attack
# against such a creature, you can pick any of the squares it occupies to
# determine if it has cover against you" — the attacker picks a clear
# bottom-row square, so the target has no cover.
assert cover_bonus_cells(grid, attacker, large_target, ranged=False) == 0
# 1x1 regressions: the aggregation choice is invisible for single cells.
assert cover_bonus_cells(grid, attacker, frozenset({(0, 3)}), ranged=False) == 4
assert cover_bonus_cells(grid, attacker, frozenset({(0, 3)}), ranged=True) == 4
assert cover_bonus_cells(grid, attacker, frozenset({(1, 3)}), ranged=False) == 0
assert cover_bonus_cells(grid, attacker, frozenset({(1, 3)}), ranged=True) == 0