feat(combat): support partial and improved cover levels
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""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,
|
||||
},
|
||||
)
|
||||
@@ -921,7 +921,7 @@ def test_observation_enriched_terrain_semantics_all_types() -> None:
|
||||
When: an Observation is built
|
||||
Then: terrain_semantics has one entry per distinct terrain type
|
||||
(sorted by type for determinism), each carrying
|
||||
type/move_cost/blocks_los/cover."""
|
||||
type/move_cost/blocks_los/cover/cover_bonus."""
|
||||
hero = make_combatant("hero")
|
||||
foe = make_combatant("foe")
|
||||
hero_state = make_state(hero, "players", (0, 0))
|
||||
@@ -932,9 +932,27 @@ def test_observation_enriched_terrain_semantics_all_types() -> None:
|
||||
obs = _snapshot(engine, hero_state)
|
||||
semantics = obs["terrain_semantics"]
|
||||
assert semantics == (
|
||||
{"type": "floor", "move_cost": 1, "blocks_los": False, "cover": False},
|
||||
{"type": "pillar", "move_cost": None, "blocks_los": False, "cover": True},
|
||||
{"type": "wall", "move_cost": None, "blocks_los": True, "cover": False},
|
||||
{
|
||||
"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": "wall",
|
||||
"move_cost": None,
|
||||
"blocks_los": True,
|
||||
"cover": False,
|
||||
"cover_bonus": 0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -950,7 +968,13 @@ def test_observation_enriched_terrain_semantics_single_type() -> None:
|
||||
|
||||
obs = _snapshot(engine, hero_state)
|
||||
assert obs["terrain_semantics"] == (
|
||||
{"type": "floor", "move_cost": 1, "blocks_los": False, "cover": False},
|
||||
{
|
||||
"type": "floor",
|
||||
"move_cost": 1,
|
||||
"blocks_los": False,
|
||||
"cover": False,
|
||||
"cover_bonus": 0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -970,7 +994,13 @@ def test_observation_enriched_terrain_semantics_no_grid_iteration_needed() -> No
|
||||
|
||||
obs = _snapshot(engine, hero_state)
|
||||
assert obs["terrain_semantics"] == (
|
||||
{"type": "wall", "move_cost": None, "blocks_los": True, "cover": False},
|
||||
{
|
||||
"type": "wall",
|
||||
"move_cost": None,
|
||||
"blocks_los": True,
|
||||
"cover": False,
|
||||
"cover_bonus": 0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -990,7 +1020,13 @@ def test_observation_enriched_terrain_semantics_no_callable_no_share() -> None:
|
||||
data = obs.to_dict()
|
||||
json.dumps(data) # must not raise
|
||||
for entry in data["terrain_semantics"]:
|
||||
assert set(entry) == {"type", "move_cost", "blocks_los", "cover"}
|
||||
assert set(entry) == {
|
||||
"type",
|
||||
"move_cost",
|
||||
"blocks_los",
|
||||
"cover",
|
||||
"cover_bonus",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user