208 lines
9.1 KiB
Python
208 lines
9.1 KiB
Python
"""Conformance B20: cover uses the ATTACKER's best corner/square.
|
|
|
|
PF1e CRB "Cover" (rule of corners): the attacker chooses the corner of its
|
|
square (and, for a multi-square creature, the occupied square) that gives it
|
|
the best shot, so a multi-cell attacker suffers the MINIMUM cover over its
|
|
own cells, not the maximum. The target side is unchanged: RAW checks ANY
|
|
line to ANY target corner, so each attacker cell is scored by the maximum
|
|
over its corner lines. 1x1 attackers are bit-identical to the old behavior.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pf1e_simulator.combat import CombatEngine
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.los import cover_bonus_cells
|
|
from pf1e_simulator.map import MapSpec, TerrainType
|
|
from pf1e_simulator.rng import ScriptedRng
|
|
from tests.test_combat import make_combatant, make_state
|
|
|
|
_FLOOR = TerrainType(type="floor", move_cost=1)
|
|
_WALL = TerrainType(type="wall", move_cost=None, blocks_los=True)
|
|
_PARTIAL = TerrainType(type="ruin", move_cost=None, cover_bonus=2)
|
|
|
|
|
|
def _grid(rows: tuple[str, ...]) -> Grid:
|
|
legend = {".": _FLOOR, "#": _WALL, "W": _PARTIAL}
|
|
return Grid.from_spec(MapSpec(name="b20", terrain=rows, legend=legend))
|
|
|
|
|
|
# Two-cell attacker {(0,0),(1,0)} shooting right at target (1,4):
|
|
# the +2 ruins at (0,2)/(1,2) sit on the top cell's lines, the +4 wall at
|
|
# (2,1) additionally sits on the bottom cell's low lines.
|
|
_CORNER_GRID = ("..W..", "..W..", ".#...", ".....")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (a) multi-cell attacker picks its best square: minimum, not maximum
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_two_cell_attacker_uses_best_cell_cover() -> None:
|
|
grid = _grid(_CORNER_GRID)
|
|
top = frozenset({(0, 0)})
|
|
bottom = frozenset({(1, 0)})
|
|
target = frozenset({(1, 4)})
|
|
# L9 rule: each cell is scored by the attacker's BEST corner (min over
|
|
# its corners of the max over target-corner lines). The top cell only
|
|
# sees +2 ruins; the bottom cell's LOW lines graze the +4 wall, but its
|
|
# best (top) corner avoids it -> 2.
|
|
assert cover_bonus_cells(grid, top, target, ranged=True) == 2
|
|
assert cover_bonus_cells(grid, bottom, target, ranged=True) == 2
|
|
# B20 fix: the attacker picks its best square -> min(2, 2) = 2.
|
|
assert (
|
|
cover_bonus_cells(grid, top | bottom, target, ranged=True) == 2
|
|
)
|
|
|
|
|
|
def test_two_cell_attacker_best_cell_alone_is_unchanged() -> None:
|
|
grid = _grid(_CORNER_GRID)
|
|
target = frozenset({(1, 4)})
|
|
# A single-square attacker is the golden path: exactly its own
|
|
# best-corner score (L9: the +4 wall graze is avoidable from (1,0)).
|
|
assert cover_bonus_cells(grid, frozenset({(0, 0)}), target, ranged=True) == 2
|
|
assert cover_bonus_cells(grid, frozenset({(1, 0)}), target, ranged=True) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (b) 1x1 attacker vs 2x2 target: target side keeps the max over corners
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_single_attacker_vs_large_target_pins_cover_level() -> None:
|
|
grid = _grid(("......", "..W...", "......"))
|
|
attacker = frozenset({(1, 0)})
|
|
large_target = frozenset({(0, 4), (0, 5), (1, 4), (1, 5)})
|
|
# Cover is evaluated over ALL target corners: the big target keeps the
|
|
# +2 it gains from the ruin, exactly as before the B20 fix.
|
|
assert cover_bonus_cells(grid, attacker, large_target, ranged=True) == 2
|
|
assert cover_bonus_cells(grid, attacker, frozenset({(1, 4)}), ranged=True) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (c) regression: single-square attacker vs wall still gets exactly +4
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_single_square_attacker_wall_cover_unchanged() -> None:
|
|
grid = _grid((".....", "..#..", "....."))
|
|
assert (
|
|
cover_bonus_cells(
|
|
grid, frozenset({(1, 0)}), frozenset({(1, 4)}), ranged=True
|
|
)
|
|
== 4
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (d) engine: Large attacker resolves with its best cell; snapshot agrees
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _large_archer_engine() -> CombatEngine:
|
|
grid = _grid(_CORNER_GRID)
|
|
archer = make_combatant(
|
|
"ogre-archer", attack_bonus=4, kind="ranged", range_increment_ft=60
|
|
)
|
|
large = archer.model_copy(update={"size": "Large"})
|
|
target = make_combatant("target", ac=13)
|
|
states = [
|
|
make_state(large, "players", (0, 0)),
|
|
make_state(target, "monsters", (1, 4)),
|
|
]
|
|
return CombatEngine(ScriptedRng([12, 3, 2, 2]), grid, states)
|
|
|
|
|
|
def test_large_attacker_ac_uses_best_cell_cover() -> None:
|
|
engine = _large_archer_engine()
|
|
archer, target = engine._states[0], engine._states[1]
|
|
assert len(archer.occupied_cells()) == 4
|
|
result = engine.resolve_attack(archer, target, archer.combatant.attacks[0])
|
|
# 13 base + 2 (best cell of the footprint), NOT 13 + 4 (worst cell).
|
|
assert result.ac == 15
|
|
assert result.hit is True # 12 + 4 = 16 >= 15
|
|
|
|
|
|
def test_observation_pair_cover_bonus_matches_attack() -> None:
|
|
engine = _large_archer_engine()
|
|
archer, target = engine._states[0], engine._states[1]
|
|
obs = engine._build_observation(archer).to_dict()
|
|
pair = next(p for p in obs["pairs"] if p["target_index"] == 1)
|
|
result = engine.resolve_attack(archer, target, archer.combatant.attacks[0])
|
|
assert pair["cover_bonus"] == 2
|
|
assert result.ac - target.combatant.ac.total == pair["cover_bonus"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (e) false-positive lock: cover still applies to touch attacks (Oracle)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ranged_touch_attack_still_gets_cover() -> None:
|
|
grid = _grid((".....", "..#..", "....."))
|
|
caster = make_combatant(
|
|
"sorcerer", attack_bonus=4, kind="touch", range_increment_ft=30
|
|
)
|
|
target = make_combatant("target", ac=13)
|
|
states = [
|
|
make_state(caster, "players", (1, 0)),
|
|
make_state(target, "monsters", (1, 4)),
|
|
]
|
|
engine = CombatEngine(ScriptedRng([12, 3]), grid, states)
|
|
result = engine.resolve_attack(states[0], states[1], caster.attacks[0])
|
|
# Touch AC 13 + 4 standard cover: cover is NOT removed for touch.
|
|
assert result.ac == 17
|
|
assert result.hit is False # 12 + 4 = 16 < 17
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# (f) A5: melee vs a multi-square target picks the BEST target square
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_melee_vs_large_target_picks_best_target_square() -> None:
|
|
grid = _grid(("..#...", "......", "......"))
|
|
attacker = frozenset({(1, 0)})
|
|
large_target = frozenset({(0, 3), (0, 4), (1, 3), (1, 4)})
|
|
# Ranged: RAW checks ANY line to ANY target corner — the wall covers the
|
|
# target's top row, so the worst cell's +4 stands.
|
|
assert cover_bonus_cells(grid, attacker, large_target, ranged=True) == 4
|
|
# Melee (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" — the attacker picks a clear
|
|
# bottom-row square, so the target has no cover.
|
|
assert cover_bonus_cells(grid, attacker, large_target, ranged=False) == 0
|
|
# 1x1 regressions: the aggregation choice is invisible for single cells.
|
|
assert cover_bonus_cells(grid, attacker, frozenset({(0, 3)}), ranged=False) == 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=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
|