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:
@@ -847,9 +847,18 @@ Règles modélisées :
|
||||
(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. En mêlée, la
|
||||
règle du pire coin attaquant est conservée (« any line from ANY corner of
|
||||
your square »).
|
||||
sur les cases de la cible en mêlée, le maximum à distance. **Mêlée
|
||||
adjacente** (conformance — SRD « Cover » : « 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 ») : 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
|
||||
« 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
|
||||
|
||||
+110
-45
@@ -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))
|
||||
|
||||
|
||||
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:
|
||||
return (num, den) if den > 0 else (-num, -den)
|
||||
|
||||
@@ -176,12 +192,13 @@ def has_cover(
|
||||
ranged: bool,
|
||||
occupied: frozenset[Pos] = frozenset(),
|
||||
) -> 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);
|
||||
Phase 0 documented deviation: melee reuses the same corner rule. For
|
||||
ranged attacks, squares occupied by other creatures also block the
|
||||
lines (soft cover, CRB); melee ignores creatures.
|
||||
Ranged (and non-adjacent melee): the attacker picks their single best
|
||||
corner (PF1e ranged cover rule). Adjacent melee: the SRD any-line rule
|
||||
(cover.md: "any line from any corner of your square to the target's
|
||||
square"). For ranged attacks, squares occupied by other creatures also
|
||||
block the lines (soft cover, CRB); melee ignores creatures.
|
||||
"""
|
||||
return has_cover_cells(
|
||||
grid,
|
||||
@@ -200,15 +217,22 @@ def has_cover_cells(
|
||||
ranged: bool,
|
||||
occupied: frozenset[Pos] = frozenset(),
|
||||
) -> 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).
|
||||
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.
|
||||
Ranged (and non-adjacent melee, which cover.md sends to the ranged
|
||||
rule): the attacker picks their single best cell (and best corner
|
||||
within it), so cover exists only when every attacker corner has at
|
||||
least one blocked target corner. Adjacent melee (some attacker square
|
||||
touching some target square): cover.md grants cover if ANY line from
|
||||
ANY corner of the attacker's square to the target's square goes
|
||||
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:
|
||||
return False
|
||||
@@ -228,6 +252,27 @@ def has_cover_cells(
|
||||
for cell_a in attacker_cells
|
||||
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(
|
||||
all(
|
||||
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.
|
||||
|
||||
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, 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.
|
||||
otherwise the cover level scored under the same rule the gate uses:
|
||||
ranged and non-adjacent melee score each attacker corner by the
|
||||
maximum over the target-corner lines and the attacker keeps its BEST
|
||||
corner (and best own cell); adjacent melee scores each touching
|
||||
attacker/target square pair by the maximum over ALL its corner lines
|
||||
(any blocked line grants cover) and the attacker keeps its best pair
|
||||
of 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:
|
||||
return 0
|
||||
@@ -291,36 +336,56 @@ def cover_bonus_cells(
|
||||
occupied if ranged else frozenset(),
|
||||
bonus_of=terrain_cover_bonus,
|
||||
)
|
||||
# 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. Ranged:
|
||||
# "choose a corner of your square. If any line from this corner to any
|
||||
# corner of the target's square passes through cover, the target has
|
||||
# cover" — each attacker corner is scored by the MAXIMUM over the target's
|
||||
# corner lines (union over all target cells), and the attacker picks its
|
||||
# BEST corner: MIN over attacker corners. Melee: the attacker may also
|
||||
# pick any single square of a multi-square target (CRB "Big Creatures and
|
||||
# Cover"), so the target side is a MIN over cells; the attacker side
|
||||
# stays the worst-corner rule ("any line from ANY corner of your
|
||||
# square").
|
||||
per_cell_best = (
|
||||
min(
|
||||
# PF1e CRB "Cover", rule of corners. Ranged: "choose a corner of your
|
||||
# square. If any line from this corner to any corner of the target's
|
||||
# square passes through cover, the target has cover" - each attacker
|
||||
# corner is scored by the MAXIMUM over the target's corner lines
|
||||
# (union over all target cells), and the attacker picks its BEST
|
||||
# corner: MIN over attacker corners, then MIN over its own cells
|
||||
# (B20). Adjacent melee: "any line from ANY corner of your square to
|
||||
# the target's square" - within a chosen square pair the level is the
|
||||
# MAXIMUM over all sixteen corner lines, and the attacker picks its
|
||||
# best pair: MIN over cells on both sides. Non-adjacent melee
|
||||
# (reach): cover.md sends the attack to the ranged rule, but the
|
||||
# attacker may pick any single square of a multi-square target (CRB
|
||||
# "Big Creatures and Cover"), so the target side is a MIN over cells
|
||||
# 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(
|
||||
lines.bonus(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)
|
||||
)
|
||||
if not ranged
|
||||
else min(
|
||||
max(
|
||||
lines.bonus(corner_a, corner_t)
|
||||
for cell_t in target_cells
|
||||
for corner_t in _corners(cell_t)
|
||||
if ranged:
|
||||
per_cell_best = (
|
||||
min(
|
||||
max(
|
||||
lines.bonus(corner_a, corner_t)
|
||||
for cell_t in target_cells
|
||||
for corner_t in _corners(cell_t)
|
||||
)
|
||||
for corner_a in _corners(cell_a)
|
||||
)
|
||||
for corner_a in _corners(cell_a)
|
||||
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
|
||||
)
|
||||
for cell_a in attacker_cells
|
||||
)
|
||||
return min(per_cell_best)
|
||||
|
||||
@@ -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