"""Tests for PF1e cover LEVELS: partial +2, standard +4, improved +8. Cover is additive: the legacy boolean ``cover`` field and the boolean has_cover/has_cover_cells API keep working unchanged; the new ``cover_bonus`` level (0/2/4/8) rides alongside them. """ from __future__ import annotations import pytest from pydantic import ValidationError from pf1e_simulator.combat import CombatEngine from pf1e_simulator.grid import Grid from pf1e_simulator.los import ( cover_bonus, cover_bonus_cells, has_cover, terrain_cover_bonus, ) 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) _PILLAR = TerrainType(type="pillar", move_cost=None, cover=True) def _grid(legend: dict[str, TerrainType], rows: tuple[str, ...]) -> Grid: spec = MapSpec(name="cover-levels", terrain=tuple(rows), legend=legend) return Grid.from_spec(spec) def _level_grid(level: int) -> Grid: """Open row with a cover_bonus obstacle at (1, 2) between (1,0) and (1,4).""" legend = {".": _FLOOR, "X": TerrainType(type="ruin", move_cost=None, cover_bonus=level)} return _grid(legend, (".....", "..X..", ".....")) # --------------------------------------------------------------------------- # (a) TerrainType.cover_bonus schema # --------------------------------------------------------------------------- @pytest.mark.parametrize("level", [0, 2, 4, 8]) def test_cover_bonus_accepts_pf1e_levels(level: int) -> None: terrain = TerrainType(type="ruin", cover_bonus=level) assert terrain.cover_bonus == level assert terrain.cover is False # boolean field untouched @pytest.mark.parametrize("level", [1, 3, 5, 6, 7, 10, -2]) def test_cover_bonus_rejects_other_levels(level: int) -> None: with pytest.raises(ValidationError): TerrainType(type="ruin", cover_bonus=level) def test_cover_bonus_defaults_to_zero() -> None: assert TerrainType(type="floor").cover_bonus == 0 # --------------------------------------------------------------------------- # (b) terrain_cover_bonus mapping # --------------------------------------------------------------------------- def test_terrain_cover_bonus_legacy_boolean_cover_is_four() -> None: assert terrain_cover_bonus(_PILLAR) == 4 def test_terrain_cover_bonus_blocks_los_is_four() -> None: assert terrain_cover_bonus(_WALL) == 4 def test_terrain_cover_bonus_explicit_level_wins() -> None: assert terrain_cover_bonus(TerrainType(type="ruin", cover_bonus=8)) == 8 assert terrain_cover_bonus(TerrainType(type="ruin", cover_bonus=2)) == 2 def test_terrain_cover_bonus_open_floor_is_zero() -> None: assert terrain_cover_bonus(_FLOOR) == 0 # --------------------------------------------------------------------------- # (c) cover_bonus / cover_bonus_cells queries # --------------------------------------------------------------------------- def test_cover_bonus_zero_in_the_open() -> None: grid = _grid({".": _FLOOR}, (".....", ".....", ".....")) assert cover_bonus(grid, (1, 0), (1, 4), ranged=True) == 0 assert cover_bonus_cells( grid, frozenset({(1, 0)}), frozenset({(1, 4)}), ranged=True ) == 0 @pytest.mark.parametrize(("level", "expected"), [(2, 2), (4, 4), (8, 8)]) def test_cover_bonus_returns_obstacle_level(level: int, expected: int) -> None: grid = _level_grid(level) assert has_cover(grid, (1, 0), (1, 4), ranged=True) is True assert cover_bonus(grid, (1, 0), (1, 4), ranged=True) == expected def test_cover_bonus_legacy_boolean_cover_is_four() -> None: grid = _grid({".": _FLOOR, "C": _PILLAR}, (".....", "..C..", ".....")) assert cover_bonus(grid, (1, 0), (1, 4), ranged=True) == 4 def test_cover_bonus_blocks_los_terrain_is_four() -> None: grid = _grid({".": _FLOOR, "#": _WALL}, (".....", "..#..", ".....")) assert cover_bonus(grid, (1, 0), (1, 4), ranged=True) == 4 def test_cover_bonus_improved_beats_standard_on_best_segment() -> None: """Wall (standard +4) and improved ruin (+8) both on the corner lines.""" legend = { ".": _FLOOR, "#": _WALL, "X": TerrainType(type="tower", move_cost=None, cover_bonus=8), } grid = _grid(legend, (".....", ".#X..", ".....")) assert has_cover(grid, (1, 0), (1, 4), ranged=True) is True assert cover_bonus(grid, (1, 0), (1, 4), ranged=True) == 8 def test_cover_bonus_soft_cover_creature_ranged_is_four() -> None: grid = _grid({".": _FLOOR}, (".....", ".....", ".....")) occupied = frozenset({(1, 2)}) assert ( cover_bonus(grid, (1, 0), (1, 4), ranged=True, occupied=occupied) == 4 ) def test_cover_bonus_soft_cover_melee_is_zero() -> None: grid = _grid({".": _FLOOR}, (".....", ".....", ".....")) occupied = frozenset({(1, 2)}) assert ( cover_bonus(grid, (1, 0), (1, 4), ranged=False, occupied=occupied) == 0 ) def test_cover_bonus_same_square_is_zero() -> None: grid = _grid({".": _FLOOR}, (".",)) assert cover_bonus(grid, (0, 0), (0, 0), ranged=True) == 0 # --------------------------------------------------------------------------- # (d) engine integration: resolve_attack applies the level # --------------------------------------------------------------------------- def _ranged_cover_engine(level: int) -> CombatEngine: legend = { ".": _FLOOR, "X": TerrainType(type="ruin", move_cost=None, cover_bonus=level), } grid = _grid(legend, (".X...",)) archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) target = make_combatant("target", ac=13) states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 3))] return CombatEngine(ScriptedRng([12, 3]), grid, states) def test_engine_improved_cover_adds_eight_to_ac() -> None: engine = _ranged_cover_engine(8) result = engine.resolve_attack( engine._states[0], engine._states[1], engine._states[0].combatant.attacks[0] ) assert result.ac == 21 # 13 + 8 improved cover assert result.hit is False # 12 + 4 = 16 < 21 def test_engine_partial_cover_adds_two_to_ac() -> None: engine = _ranged_cover_engine(2) result = engine.resolve_attack( engine._states[0], engine._states[1], engine._states[0].combatant.attacks[0] ) assert result.ac == 15 # 13 + 2 partial cover assert result.hit is True # 12 + 4 = 16 >= 15 def test_engine_legacy_boolean_cover_still_adds_four() -> None: legend = {".": _FLOOR, "C": _PILLAR} grid = _grid(legend, (".C...",)) archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) target = make_combatant("target", ac=13) states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 3))] engine = CombatEngine(ScriptedRng([12, 3]), grid, states) result = engine.resolve_attack(states[0], states[1], archer.attacks[0]) assert result.ac == 17 # 13 + 4, identical to the pre-levels behavior # --------------------------------------------------------------------------- # (e) snapshot: terrain_semantics exposes cover_bonus additively # --------------------------------------------------------------------------- def test_snapshot_terrain_semantics_exposes_cover_bonus() -> None: legend = { ".": _FLOOR, "C": _PILLAR, "X": TerrainType(type="tower", move_cost=None, cover_bonus=8), } grid = _grid(legend, ("..X.", ".C..")) hero = make_combatant("hero") foe = make_combatant("foe") states = [make_state(hero, "players", (0, 0)), make_state(foe, "monsters", (1, 1))] engine = CombatEngine(ScriptedRng([]), grid, states) obs = engine._build_observation(states[0]).to_dict() assert obs["terrain_semantics"] == ( {"type": "floor", "move_cost": 1, "blocks_los": False, "cover": False, "cover_bonus": 0}, { "type": "pillar", "move_cost": None, "blocks_los": False, "cover": True, "cover_bonus": 0, }, { "type": "tower", "move_cost": None, "blocks_los": False, "cover": False, "cover_bonus": 8, }, )