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 »,
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user