fix(rules): adjacent melee cover uses the SRD any-line rule (cover.md: 'any line from any corner of your square to the target's square goes through a wall') - the gate previously applied the ranged best-corner rule to distance-1 melee, systematically under-granting cover; non-adjacent melee (reach) keeps the ranged rule, bonus scoring aligned per branch, README 'pire coin' wording updated

This commit is contained in:
2026-09-19 15:35:56 +02:00
parent 9f8655da76
commit 27ce33ba67
4 changed files with 225 additions and 50 deletions
+12 -3
View File
@@ -847,9 +847,18 @@ 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. En mêlée, la sur les cases de la cible en mêlée, le maximum à distance. **Mêlée
règle du pire coin attaquant est conservée (« any line from ANY corner of adjacente** (conformance — SRD « Cover » : « When making a melee attack
your square »). against an adjacent target, your target has cover if any line from any
corner of your square to the target's square goes through a wall ») : la
règle est celle de **toute ligne** — une seule ligne coin-à-coin bloquée
suffit à donner le couvert, l'attaquant ne choisit plus son meilleur coin
(score : maximum sur les seize lignes de la paire de cases, minimum sur
les paires de cases adjacentes). **Mêlée non adjacente** (arme à allonge,
distance > 1) : le SRD renvoie à la règle à distance (« use the rules for
determining cover from ranged attacks ») — l'attaquant garde son **meilleur
coin** (minimum sur ses coins du maximum des lignes), avec le choix de la
case de cible multi-cases.
- 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
+103 -38
View File
@@ -29,6 +29,22 @@ def _corners(pos: Pos) -> tuple[Pt, Pt, Pt, Pt]:
return ((col, row), (col + 1, row), (col, row + 1), (col + 1, row + 1)) return ((col, row), (col + 1, row), (col, row + 1), (col + 1, row + 1))
def _adjacent(a: Pos, b: Pos) -> bool:
"""True if the two squares touch (share an edge or a corner)."""
return max(abs(a[0] - b[0]), abs(a[1] - b[1])) == 1
def _melee_is_adjacent(
attacker_cells: frozenset[Pos], target_cells: frozenset[Pos]
) -> bool:
"""True if some attacker square touches some target square."""
return any(
_adjacent(cell_a, cell_t)
for cell_a in attacker_cells
for cell_t in target_cells
)
def _norm(num: int, den: int) -> _Rat: def _norm(num: int, den: int) -> _Rat:
return (num, den) if den > 0 else (-num, -den) return (num, den) if den > 0 else (-num, -den)
@@ -176,12 +192,13 @@ def has_cover(
ranged: bool, ranged: bool,
occupied: frozenset[Pos] = frozenset(), occupied: frozenset[Pos] = frozenset(),
) -> bool: ) -> bool:
"""Cover iff every attacker corner has at least one blocked target corner. """Cover with the SRD corner rule (see has_cover_cells).
The attacker picks their single best corner (PF1e ranged cover rule); Ranged (and non-adjacent melee): the attacker picks their single best
Phase 0 documented deviation: melee reuses the same corner rule. For corner (PF1e ranged cover rule). Adjacent melee: the SRD any-line rule
ranged attacks, squares occupied by other creatures also block the (cover.md: "any line from any corner of your square to the target's
lines (soft cover, CRB); melee ignores creatures. square"). For ranged attacks, squares occupied by other creatures also
block the lines (soft cover, CRB); melee ignores creatures.
""" """
return has_cover_cells( return has_cover_cells(
grid, grid,
@@ -200,15 +217,22 @@ def has_cover_cells(
ranged: bool, ranged: bool,
occupied: frozenset[Pos] = frozenset(), occupied: frozenset[Pos] = frozenset(),
) -> bool: ) -> bool:
"""Cover iff every attacker corner has at least one blocked target corner. """Cover for an attack between two sets of cells, per the SRD corner rules.
The attacker picks their single best cell (and best corner within it). Ranged (and non-adjacent melee, which cover.md sends to the ranged
Target side: ranged attacks check ANY line to ANY corner of ANY target rule): the attacker picks their single best cell (and best corner
cell (union over the target's cells); melee attacks may pick any single within it), so cover exists only when every attacker corner has at
square of a multi-square target (CRB "Big Creatures and Cover"), so least one blocked target corner. Adjacent melee (some attacker square
cover exists only when no attacker corner sees every corner of SOME touching some target square): cover.md grants cover if ANY line from
target cell unblocked. For 1x1 creatures both sides are the single-cell ANY corner of the attacker's square to the target's square goes
corner rule. through a wall, so cover exists as soon as one corner line is blocked.
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. For ranged attacks, squares occupied
by other creatures block the lines (soft cover); melee ignores them.
""" """
if attacker_cells & target_cells: if attacker_cells & target_cells:
return False return False
@@ -228,6 +252,27 @@ 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)
) )
if _melee_is_adjacent(attacker_cells, target_cells):
# SRD cover.md, adjacent melee: "your target has cover if any line
# from any corner of your square to the target's square goes
# through a wall". One blocked corner line is enough. With a
# multi-square attacker the defender still picks the pair it
# attacks through (CRB "Big Creatures and Cover" applies to both
# sides), so cover exists only when EVERY touching attacker /
# target square pair has at least one blocked corner line.
return all(
any(
lines.blocked(corner_a, corner_t)
for corner_a in _corners(cell_a)
for corner_t in _corners(cell_t)
)
for cell_a in attacker_cells
for cell_t in target_cells
if _adjacent(cell_a, cell_t)
)
# Non-adjacent melee (reach weapons): cover.md sends these to the
# ranged rule, but the attacker may still pick any single square of a
# multi-square target (CRB "Big Creatures and Cover").
return not any( return not any(
all( all(
not lines.blocked(corner_a, corner_t) not lines.blocked(corner_a, corner_t)
@@ -268,15 +313,15 @@ def cover_bonus_cells(
"""Effective cover bonus (0/2/4/8) for an attack between two sets of cells. """Effective cover bonus (0/2/4/8) for an attack between two sets of 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 scored under the same rule the gate uses:
attacker cell is scored by the per-cell corner rule, and the attacker ranged and non-adjacent melee score each attacker corner by the
picks the lowest-scoring (clearest) cell. Target side: ranged attacks are maximum over the target-corner lines and the attacker keeps its BEST
scored by the maximum over ALL the target's cells; melee attacks may pick corner (and best own cell); adjacent melee scores each touching
any single square of the target (CRB "Big Creatures and Cover"), so each attacker/target square pair by the maximum over ALL its corner lines
attacker cell is scored by the MINIMUM over the target's cells. Interposed (any blocked line grants cover) and the attacker keeps its best pair
creatures grant soft cover (+4) on ranged attacks only, exactly as in of cells. Interposed creatures grant soft cover (+4) on ranged
has_cover_cells. For 1x1 attacker and target this is exactly the attacks only, exactly as in has_cover_cells. For 1x1 attacker and
single-cell value. target this is exactly the single-cell value.
""" """
if not attacker_cells or not target_cells: if not attacker_cells or not target_cells:
return 0 return 0
@@ -291,29 +336,35 @@ def cover_bonus_cells(
occupied if ranged else frozenset(), occupied if ranged else frozenset(),
bonus_of=terrain_cover_bonus, bonus_of=terrain_cover_bonus,
) )
# PF1e CRB "Cover", rule of corners: the attacker chooses the corner (and, # PF1e CRB "Cover", rule of corners. Ranged: "choose a corner of your
# for a multi-square creature, the occupied square) that gives it the best # square. If any line from this corner to any corner of the target's
# shot, so it suffers only the MINIMUM cover over its own cells. Ranged: # square passes through cover, the target has cover" - each attacker
# "choose a corner of your square. If any line from this corner to any # corner is scored by the MAXIMUM over the target's corner lines
# corner of the target's square passes through cover, the target has # (union over all target cells), and the attacker picks its BEST
# cover" — each attacker corner is scored by the MAXIMUM over the target's # corner: MIN over attacker corners, then MIN over its own cells
# corner lines (union over all target cells), and the attacker picks its # (B20). Adjacent melee: "any line from ANY corner of your square to
# BEST corner: MIN over attacker corners. Melee: the attacker may also # the target's square" - within a chosen square pair the level is the
# pick any single square of a multi-square target (CRB "Big Creatures and # MAXIMUM over all sixteen corner lines, and the attacker picks its
# Cover"), so the target side is a MIN over cells; the attacker side # best pair: MIN over cells on both sides. Non-adjacent melee
# stays the worst-corner rule ("any line from ANY corner of your # (reach): cover.md sends the attack to the ranged rule, but the
# square"). # attacker may pick any single square of a multi-square target (CRB
per_cell_best = ( # "Big Creatures and Cover"), so the target side is a MIN over cells
min( # while the attacker side stays the best-corner MIN.
adjacent_melee = not ranged and _melee_is_adjacent(attacker_cells, target_cells)
if adjacent_melee:
return min(
max( 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 corner_t in _corners(cell_t) for corner_t in _corners(cell_t)
) )
for cell_a in attacker_cells
for cell_t in target_cells for cell_t in target_cells
if _adjacent(cell_a, cell_t)
) )
if not ranged if ranged:
else min( per_cell_best = (
min(
max( max(
lines.bonus(corner_a, corner_t) lines.bonus(corner_a, corner_t)
for cell_t in target_cells for cell_t in target_cells
@@ -323,4 +374,18 @@ def cover_bonus_cells(
) )
for cell_a in attacker_cells for cell_a in attacker_cells
) )
else:
per_cell_best = (
min(
min(
max(
lines.bonus(corner_a, corner_t)
for corner_t in _corners(cell_t)
)
for corner_a in _corners(cell_a)
)
for cell_t in target_cells
)
for cell_a in attacker_cells
)
return min(per_cell_best) return min(per_cell_best)
+28
View File
@@ -177,3 +177,31 @@ def test_melee_vs_large_target_picks_best_target_square() -> None:
assert cover_bonus_cells(grid, attacker, frozenset({(0, 3)}), ranged=True) == 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=False) == 0
assert cover_bonus_cells(grid, attacker, frozenset({(1, 3)}), ranged=True) == 0 assert cover_bonus_cells(grid, attacker, frozenset({(1, 3)}), ranged=True) == 0
# ---------------------------------------------------------------------------
# (g) adjacent melee uses the SRD any-line cover rule (cover.md), not the
# ranged best-corner rule - engine AC effect
# ---------------------------------------------------------------------------
def test_adjacent_melee_grazing_wall_grants_cover_to_ac() -> None:
"""Wall (0,1) grazes one corner line of the adjacent pair (1,0)-(1,1).
SRD cover.md: "When making a melee attack against an adjacent target,
your target has cover if any line from any corner of your square to
the target's square goes through a wall." The ranged rule would find
the attacker's top corner with all lines clear (no cover); the melee
rule grants +4, and the engine's resolve_attack must apply it.
"""
grid = _grid((".#", ".."))
attacker = make_combatant("gnoll", attack_bonus=4)
target = make_combatant("warden", ac=13)
states = [
make_state(attacker, "players", (1, 0)),
make_state(target, "monsters", (1, 1)),
]
engine = CombatEngine(ScriptedRng([12, 3, 12, 3]), grid, states)
result = engine.resolve_attack(states[0], states[1], attacker.attacks[0])
assert result.ac == 17 # 13 + 4 melee-adjacent cover
assert result.hit is False # 12 + 4 = 16 < 17
+75 -2
View File
@@ -8,7 +8,12 @@ on a blocking square's vertex do not count as passing through it.
from __future__ import annotations from __future__ import annotations
from pf1e_simulator.grid import Grid from pf1e_simulator.grid import Grid
from pf1e_simulator.los import has_cover, has_line_of_effect from pf1e_simulator.los import (
cover_bonus,
has_cover,
has_cover_cells,
has_line_of_effect,
)
from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.map import MapSpec, TerrainType
@@ -78,7 +83,7 @@ def test_cover_diagonal_past_wall_vertex() -> None:
assert has_cover(grid, (0, 0), (1, 1), ranged=True) is True assert has_cover(grid, (0, 0), (1, 1), ranged=True) is True
def test_cover_melee_uses_same_corner_rule_in_phase0() -> None: def test_cover_melee_adjacent_wall_vertex_grant_cover() -> None:
grid = make_grid([".#.", "#.."]) grid = make_grid([".#.", "#.."])
assert has_cover(grid, (0, 0), (1, 1), ranged=False) is True assert has_cover(grid, (0, 0), (1, 1), ranged=False) is True
open_grid = make_grid(["...", "..."]) open_grid = make_grid(["...", "..."])
@@ -90,6 +95,74 @@ def test_cover_same_square_is_false() -> None:
assert has_cover(grid, (0, 0), (0, 0), ranged=True) is False assert has_cover(grid, (0, 0), (0, 0), ranged=True) is False
# ---------------------------------------------------------------------------
# Adjacent melee uses the SRD any-line rule (cover.md), not the ranged
# best-corner rule: "When making a melee attack against an adjacent target,
# your target has cover if any line from any corner of your square to the
# target's square goes through a wall."
# ---------------------------------------------------------------------------
def test_adjacent_melee_any_blocked_line_grants_cover() -> None:
"""Wall (0,1) blocks ONE corner line (0,1)->(2,1) of adjacent (1,0)->(1,1).
The ranged best-corner rule finds attacker corner (1,2) with all four
lines clear (no cover); the melee-adjacent any-line rule grants cover
from the single grazing line. This is the divergence the fix closes.
"""
grid = make_grid([".#", ".."])
assert has_cover(grid, (1, 0), (1, 1), ranged=True) is False
assert has_cover(grid, (1, 0), (1, 1), ranged=False) is True
def test_adjacent_melee_cover_bonus_grants_four() -> None:
grid = make_grid([".#", ".."])
assert cover_bonus(grid, (1, 0), (1, 1), ranged=False) == 4
assert cover_bonus(grid, (1, 0), (1, 1), ranged=True) == 0
def test_adjacent_melee_worst_line_sets_level() -> None:
"""Pillar (1,1) grazes ONE line (1,2)->(2,2) of adjacent (2,0)->(2,1):
the any-line rule takes the MAX over the sixteen corner lines, so the
target keeps the +4 even though the attacker's best corner (0,3) sees
every line clear (ranged level 0)."""
grid = make_grid(["..", ".C", ".."])
assert cover_bonus(grid, (2, 0), (2, 1), ranged=False) == 4
assert cover_bonus(grid, (2, 0), (2, 1), ranged=True) == 0
def test_adjacent_melee_open_field_no_cover() -> None:
grid = make_grid(["..", ".."])
assert has_cover(grid, (0, 0), (0, 1), ranged=False) is False
assert cover_bonus(grid, (0, 0), (0, 1), ranged=False) == 0
def test_nonadjacent_melee_keeps_ranged_best_corner_rule() -> None:
"""Reach attack at distance 2 (wall (0,1) off-axis): cover.md sends it
to the ranged rule. Line (0,1)->(2,1) grazes the wall's border (the
adjacent-melee rule would grant cover) but the attacker's top corner
(0,2) sees every target corner clear -> no cover, exactly as ranged."""
grid = make_grid([".#", ".."])
assert has_cover(grid, (1, 0), (1, 2), ranged=True) is False
assert has_cover(grid, (1, 0), (1, 2), ranged=False) is False
def test_adjacent_melee_multi_cell_attacker_picks_clear_pair() -> None:
"""2x1 attacker {(1,0),(1,1)} vs target (0,1): both squares touch it.
Wall (0,2) blocks a line of the (1,1) pair (vertical x=2 along the
wall's border) but the (1,0) pair sees every line clear - the attacker
attacks through its clear square -> no cover (CRB "Big Creatures and
Cover": the big creature picks its own square). Without the wall the
same pair scan gives no cover either."""
grid = make_grid(["..#", "..."])
attacker = frozenset({(1, 0), (1, 1)})
target = frozenset({(0, 1)})
assert has_cover_cells(grid, attacker, target, ranged=False) is False
assert has_cover_cells(grid, attacker, target, ranged=True) is False
open_grid = make_grid(["...", "..."])
assert has_cover_cells(open_grid, attacker, target, ranged=False) is False
def test_soft_cover_creature_between_grants_cover() -> None: def test_soft_cover_creature_between_grants_cover() -> None:
"""A creature between shooter and target grants cover on ranged attacks.""" """A creature between shooter and target grants cover on ranged attacks."""
grid = make_grid([".....", ".....", "....."]) grid = make_grid([".....", ".....", "....."])