192 lines
7.3 KiB
Python
192 lines
7.3 KiB
Python
"""Tests for corner-rule line of effect and cover (los.py).
|
|
|
|
Geometry convention: a line is blocked when its relative interior touches a
|
|
blocking square (interior, border, or vertex); lines that merely start or end
|
|
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 (
|
|
cover_bonus,
|
|
has_cover,
|
|
has_cover_cells,
|
|
has_line_of_effect,
|
|
)
|
|
from pf1e_simulator.map import MapSpec, TerrainType
|
|
|
|
|
|
def make_grid(rows: list[str]) -> Grid:
|
|
legend = {
|
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
|
".": TerrainType(type="floor", move_cost=1),
|
|
"T": TerrainType(type="rubble", move_cost=2),
|
|
"C": TerrainType(type="pillar", move_cost=None, cover=True),
|
|
}
|
|
spec = MapSpec(name="test", terrain=tuple(rows), legend=legend)
|
|
return Grid.from_spec(spec)
|
|
|
|
|
|
def test_los_open_field() -> None:
|
|
grid = make_grid(["...", "...", "..."])
|
|
assert has_line_of_effect(grid, (0, 0), (2, 2)) is True
|
|
|
|
|
|
def test_los_same_square() -> None:
|
|
grid = make_grid(["."])
|
|
assert has_line_of_effect(grid, (0, 0), (0, 0)) is True
|
|
|
|
|
|
def test_los_blocked_by_wall_between() -> None:
|
|
grid = make_grid([".#.", "..."])
|
|
assert has_line_of_effect(grid, (0, 0), (0, 2)) is False
|
|
|
|
|
|
def test_los_through_doorway_gap() -> None:
|
|
grid = make_grid(["##.##", "##.##"])
|
|
assert has_line_of_effect(grid, (0, 2), (1, 2)) is True
|
|
|
|
|
|
def test_los_pinched_vertex_stays_open() -> None:
|
|
grid = make_grid([".#.", "#.."])
|
|
assert has_line_of_effect(grid, (0, 0), (1, 1)) is True
|
|
|
|
|
|
def test_los_blocked_past_wall_line() -> None:
|
|
grid = make_grid(["...#.", "....."])
|
|
assert has_line_of_effect(grid, (0, 0), (0, 4)) is False
|
|
|
|
|
|
def test_cover_open_field() -> None:
|
|
grid = make_grid([".....", "....."])
|
|
assert has_cover(grid, (0, 0), (0, 4), ranged=True) is False
|
|
|
|
|
|
def test_cover_pillar_between() -> None:
|
|
grid = make_grid([".....", "..C..", "....."])
|
|
assert has_cover(grid, (1, 0), (1, 4), ranged=True) is True
|
|
|
|
|
|
def test_no_cover_when_pillar_off_axis() -> None:
|
|
grid = make_grid(["..C..", "....."])
|
|
assert has_cover(grid, (1, 0), (1, 4), ranged=True) is False
|
|
|
|
|
|
def test_no_cover_adjacent_with_wall_behind_target() -> None:
|
|
grid = make_grid(["..#"])
|
|
assert has_cover(grid, (0, 0), (0, 1), ranged=True) is False
|
|
|
|
|
|
def test_cover_diagonal_past_wall_vertex() -> None:
|
|
grid = make_grid([".#.", "#.."])
|
|
assert has_cover(grid, (0, 0), (1, 1), ranged=True) is True
|
|
|
|
|
|
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(["...", "..."])
|
|
assert has_cover(open_grid, (0, 0), (1, 1), ranged=False) is False
|
|
|
|
|
|
def test_cover_same_square_is_false() -> None:
|
|
grid = make_grid(["."])
|
|
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([".....", ".....", "....."])
|
|
occupied = frozenset({(1, 2)})
|
|
assert has_cover(grid, (1, 0), (1, 4), ranged=True, occupied=occupied) is True
|
|
|
|
|
|
def test_soft_cover_melee_ignores_creatures() -> None:
|
|
"""Melee attacks ignore creatures for soft cover (CRB: ranged only)."""
|
|
grid = make_grid([".....", ".....", "....."])
|
|
occupied = frozenset({(1, 2)})
|
|
assert has_cover(grid, (1, 0), (1, 4), ranged=False, occupied=occupied) is False
|
|
|
|
|
|
def test_soft_cover_creature_behind_target_ignored() -> None:
|
|
"""A creature behind the target does not grant cover."""
|
|
grid = make_grid(["......", "......"])
|
|
occupied = frozenset({(1, 5)})
|
|
assert has_cover(grid, (1, 0), (1, 4), ranged=True, occupied=occupied) is False
|
|
|
|
|
|
def test_soft_cover_creature_off_axis_ignored() -> None:
|
|
"""A creature off the attack line does not grant cover."""
|
|
grid = make_grid([".....", "....."])
|
|
occupied = frozenset({(0, 2)})
|
|
assert has_cover(grid, (1, 0), (1, 4), ranged=True, occupied=occupied) is False
|