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
+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 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
@@ -78,7 +83,7 @@ def test_cover_diagonal_past_wall_vertex() -> None:
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([".#.", "#.."])
assert has_cover(grid, (0, 0), (1, 1), ranged=False) is True
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
# ---------------------------------------------------------------------------
# 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:
"""A creature between shooter and target grants cover on ranged attacks."""
grid = make_grid([".....", ".....", "....."])