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:
@@ -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
|
||||
|
||||
+38
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user