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:
@@ -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({(1, 3)}), ranged=False) == 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
@@ -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([".....", ".....", "....."])
|
||||
|
||||
Reference in New Issue
Block a user