"""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)}) # Per-cell scores pin the geometry: the top cell only sees +2 ruins, # the bottom cell's low lines also graze the +4 wall. assert cover_bonus_cells(grid, top, target, ranged=True) == 2 assert cover_bonus_cells(grid, bottom, target, ranged=True) == 4 # B20 fix: the attacker picks its best square -> min(2, 4) = 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 score. assert cover_bonus_cells(grid, frozenset({(0, 0)}), target, ranged=True) == 2 assert cover_bonus_cells(grid, frozenset({(1, 0)}), target, ranged=True) == 4 # --------------------------------------------------------------------------- # (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