diff --git a/README.md b/README.md index b5da625..bf59dbc 100644 --- a/README.md +++ b/README.md @@ -650,9 +650,14 @@ Règles modélisées : 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. Le côté cible reste - évalué par la règle RAW : toute ligne vers tout coin des cases de la cible - compte (maximum sur les coins de la cible). + 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. - 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 7595423..4b7b0a5 100644 --- a/src/pf1e_simulator/los.py +++ b/src/pf1e_simulator/los.py @@ -202,9 +202,13 @@ def has_cover_cells( ) -> 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); - the target's corners are the union over all its cells. For 1x1 creatures - this is exactly the single-cell corner rule. + 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 @@ -214,14 +218,24 @@ def has_cover_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 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) + for cell_t in target_cells ) @@ -255,10 +269,13 @@ def cover_bonus_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 against all of the - target's cells, and the attacker picks the lowest-scoring (clearest) - cell. Interposed creatures grant soft cover (+4) on ranged attacks only, - exactly as in has_cover_cells. For a 1x1 attacker this is exactly the + 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: @@ -277,10 +294,20 @@ 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 is unchanged: RAW checks ANY line to ANY target corner, so - # each attacker cell is scored by the MAXIMUM over its corner lines. + # 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. per_cell_best = ( - max( + 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 max( lines.bonus(corner_a, corner_t) for corner_a in _corners(cell_a) for cell_t in target_cells diff --git a/tests/test_conformance_cover.py b/tests/test_conformance_cover.py index 076ddd3..a754773 100644 --- a/tests/test_conformance_cover.py +++ b/tests/test_conformance_cover.py @@ -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. assert result.ac == 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