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>
This commit is contained in:
2026-09-12 14:57:56 +02:00
parent a989266747
commit 5bf2850e2f
3 changed files with 45 additions and 25 deletions
+11 -2
View File
@@ -695,7 +695,14 @@ Règles modélisées :
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
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 conformance L9 (`los.py`, `cover_bonus_cells`) : à distance, le score de
chaque case attaquante est le **minimum sur ses quatre coins** de (maximum
sur les lignes vers les coins de la cible) — « choose a corner of your
square » — puis le minimum sur les cases de l'attaquant ; un créneau
n'est donc jamais compté contre l'attaquant si l'un de ses coins l'évite
(le bonus reste cohérent avec la porte `has_cover_cells`, qui teste déjà
« tout coin de l'attaquant »). Une créature multi-cases (Large +)
ne subit que le couvert de SA meilleure
case occupée (minimum sur ses cases), pas le pire. Côté cible : à distance, case occupée (minimum sur ses cases), pas le pire. Côté cible : à distance,
toute ligne vers tout coin des cases de la cible compte (maximum sur les toute ligne vers tout coin des cases de la cible compte (maximum sur les
coins de la cible) ; **en mêlée**, l'attaquant peut choisir n'importe coins de la cible) ; **en mêlée**, l'attaquant peut choisir n'importe
@@ -703,7 +710,9 @@ Règles modélisées :
(conformance A5 — CRB « Big Creatures and Cover » : « when making a melee (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 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 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. sur les cases de la cible en mêlée, le maximum à distance. En mêlée, la
règle du pire coin attaquant est conservée (« any line from ANY corner of
your square »).
- 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
+14 -6
View File
@@ -293,10 +293,16 @@ 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. Ranged:
# target side follows the same choice rule: RAW checks ANY line to ANY # "choose a corner of your square. If any line from this corner to any
# corner of the (chosen) square, scored by the MAXIMUM over corner lines — # corner of the target's square passes through cover, the target has
# union over all target cells for ranged, best single cell for melee. # 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 = ( per_cell_best = (
min( min(
max( max(
@@ -307,12 +313,14 @@ def cover_bonus_cells(
for cell_t in target_cells for cell_t in target_cells
) )
if not ranged if not ranged
else max( else min(
max(
lines.bonus(corner_a, corner_t) lines.bonus(corner_a, corner_t)
for corner_a in _corners(cell_a)
for cell_t in target_cells for cell_t in target_cells
for corner_t in _corners(cell_t) for corner_t in _corners(cell_t)
) )
for corner_a in _corners(cell_a)
)
for cell_a in attacker_cells for cell_a in attacker_cells
) )
return min(per_cell_best) return min(per_cell_best)
+9 -6
View File
@@ -43,11 +43,13 @@ def test_two_cell_attacker_uses_best_cell_cover() -> None:
top = frozenset({(0, 0)}) top = frozenset({(0, 0)})
bottom = frozenset({(1, 0)}) bottom = frozenset({(1, 0)})
target = frozenset({(1, 4)}) target = frozenset({(1, 4)})
# Per-cell scores pin the geometry: the top cell only sees +2 ruins, # L9 rule: each cell is scored by the attacker's BEST corner (min over
# the bottom cell's low lines also graze the +4 wall. # its corners of the max over target-corner lines). The top cell only
# sees +2 ruins; the bottom cell's LOW lines graze the +4 wall, but its
# best (top) corner avoids it -> 2.
assert cover_bonus_cells(grid, top, target, ranged=True) == 2 assert cover_bonus_cells(grid, top, target, ranged=True) == 2
assert cover_bonus_cells(grid, bottom, target, ranged=True) == 4 assert cover_bonus_cells(grid, bottom, target, ranged=True) == 2
# B20 fix: the attacker picks its best square -> min(2, 4) = 2. # B20 fix: the attacker picks its best square -> min(2, 2) = 2.
assert ( assert (
cover_bonus_cells(grid, top | bottom, target, ranged=True) == 2 cover_bonus_cells(grid, top | bottom, target, ranged=True) == 2
) )
@@ -56,9 +58,10 @@ def test_two_cell_attacker_uses_best_cell_cover() -> None:
def test_two_cell_attacker_best_cell_alone_is_unchanged() -> None: def test_two_cell_attacker_best_cell_alone_is_unchanged() -> None:
grid = _grid(_CORNER_GRID) grid = _grid(_CORNER_GRID)
target = frozenset({(1, 4)}) target = frozenset({(1, 4)})
# A single-square attacker is the golden path: exactly its own score. # A single-square attacker is the golden path: exactly its own
# best-corner score (L9: the +4 wall graze is avoidable from (1,0)).
assert cover_bonus_cells(grid, frozenset({(0, 0)}), target, ranged=True) == 2 assert cover_bonus_cells(grid, frozenset({(0, 0)}), target, ranged=True) == 2
assert cover_bonus_cells(grid, frozenset({(1, 0)}), target, ranged=True) == 4 assert cover_bonus_cells(grid, frozenset({(1, 0)}), target, ranged=True) == 2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------