From 5bf2850e2f034f62a47e159a0eee5abab92da168 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 12 Sep 2026 14:57:56 +0200 Subject: [PATCH] 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 --- README.md | 31 ++++++++++++++++++++----------- src/pf1e_simulator/los.py | 24 ++++++++++++++++-------- tests/test_conformance_cover.py | 15 +++++++++------ 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 8e8d8fe..ac5d105 100644 --- a/README.md +++ b/README.md @@ -693,17 +693,26 @@ Règles modélisées : (+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) sur la touche et la confirmation de critique ; couvert mou : une créature active entre 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 », - 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 - 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 - 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. + 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** — + 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, + 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 + 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. 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 « 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 diff --git a/src/pf1e_simulator/los.py b/src/pf1e_simulator/los.py index 4b7b0a5..acea226 100644 --- a/src/pf1e_simulator/los.py +++ b/src/pf1e_simulator/los.py @@ -293,10 +293,16 @@ def cover_bonus_cells( ) # 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. The - # target side follows the same choice rule: RAW checks ANY line to ANY - # corner of the (chosen) square, scored by the MAXIMUM over corner lines — - # union over all target cells for ranged, best single cell for melee. + # 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( @@ -307,11 +313,13 @@ def cover_bonus_cells( for cell_t in target_cells ) if not ranged - else max( - lines.bonus(corner_a, corner_t) + 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_t in target_cells - for corner_t in _corners(cell_t) ) for cell_a in attacker_cells ) diff --git a/tests/test_conformance_cover.py b/tests/test_conformance_cover.py index a754773..73a3a31 100644 --- a/tests/test_conformance_cover.py +++ b/tests/test_conformance_cover.py @@ -43,11 +43,13 @@ def test_two_cell_attacker_uses_best_cell_cover() -> None: top = frozenset({(0, 0)}) bottom = frozenset({(1, 0)}) target = frozenset({(1, 4)}) - # Per-cell scores pin the geometry: the top cell only sees +2 ruins, - # the bottom cell's low lines also graze the +4 wall. + # L9 rule: each cell is scored by the attacker's BEST corner (min over + # 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, bottom, target, ranged=True) == 4 - # B20 fix: the attacker picks its best square -> min(2, 4) = 2. + assert cover_bonus_cells(grid, bottom, target, ranged=True) == 2 + # B20 fix: the attacker picks its best square -> min(2, 2) = 2. assert ( 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: grid = _grid(_CORNER_GRID) 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({(1, 0)}), target, ranged=True) == 4 + assert cover_bonus_cells(grid, frozenset({(1, 0)}), target, ranged=True) == 2 # ---------------------------------------------------------------------------