Files
pf1e-simulator/tests/test_elevation.py
T

306 lines
9.4 KiB
Python

"""Tests for B5.1 applied elevation: movement cost, high ground, from_spec."""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.combat import (
CombatantState,
CombatEngine,
)
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType, load_map
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng, SeededRng
MAPS_DIR = Path(__file__).resolve().parents[1] / "data" / "maps"
_FLOOR = TerrainType(type="floor", move_cost=1)
_WALL = TerrainType(type="wall", move_cost=None, blocks_los=True)
_LEGEND: dict[str, TerrainType] = {
".": _FLOOR,
"#": _WALL,
}
def _make_floor_grid(
rows: int,
cols: int,
elevation: tuple[tuple[int, ...], ...] | None = None,
) -> Grid:
cells = tuple(tuple(_FLOOR for _ in range(cols)) for _ in range(rows))
return Grid(cells, elevation=elevation)
def _make_combatant(
cid: str,
*,
kind: str = "melee",
attack_bonus: int = 2,
weapon_name: str | None = None,
ac: int = 10,
) -> Combatant:
name = weapon_name or ("short bow" if kind == "ranged" else "short sword")
attack = AttackSpec(
id=f"{cid}-w",
name=name,
kind=kind,
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["slashing"])],
damage_bonus=0,
crit_range=20,
crit_mult=2,
range_increment_ft=60 if kind == "ranged" else None,
count=1,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=attack_bonus,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
)
def _make_state(
combatant: Combatant, side: str, pos: tuple[int, int]
) -> CombatantState:
return CombatantState(
combatant=combatant, side=side, pos=pos, hp=combatant.hp_max
)
# ---------------------------------------------------------------------------
# 1. step_cost: elevation adds climb cost
# ---------------------------------------------------------------------------
def test_step_cost_climb_0_to_1_costs_plus_1() -> None:
elev = ((0, 0), (1, 0))
grid = _make_floor_grid(2, 2, elevation=elev)
assert grid.step_cost((0, 0), (1, 0), 0) == 2
def test_step_cost_climb_0_to_2_costs_plus_2() -> None:
elev = ((0, 0), (2, 0))
grid = _make_floor_grid(2, 2, elevation=elev)
assert grid.step_cost((0, 0), (1, 0), 0) == 3
def test_step_cost_descend_costs_no_extra() -> None:
elev = ((2, 0), (0, 0))
grid = _make_floor_grid(2, 2, elevation=elev)
assert grid.step_cost((0, 0), (1, 0), 0) == 1
def test_step_cost_flat_unchanged() -> None:
elev = ((0, 0), (0, 0))
grid = _make_floor_grid(2, 2, elevation=elev)
assert grid.step_cost((0, 0), (1, 0), 0) == 1
def test_step_cost_no_elevation_layer_unchanged() -> None:
grid = _make_floor_grid(2, 2, elevation=None)
assert grid.step_cost((0, 0), (1, 0), 0) == 1
# ---------------------------------------------------------------------------
# 2. elevation_at
# ---------------------------------------------------------------------------
def test_elevation_at_returns_height() -> None:
elev = ((0, 1, 2), (3, 4, 5))
grid = _make_floor_grid(2, 3, elevation=elev)
assert grid.elevation_at((0, 0)) == 0
assert grid.elevation_at((0, 1)) == 1
assert grid.elevation_at((1, 2)) == 5
def test_elevation_at_no_layer_returns_zero() -> None:
grid = _make_floor_grid(2, 2, elevation=None)
assert grid.elevation_at((0, 0)) == 0
def test_elevation_at_out_of_bounds_returns_zero() -> None:
elev = ((0, 1),)
grid = _make_floor_grid(1, 2, elevation=elev)
assert grid.elevation_at((-1, 0)) == 0
assert grid.elevation_at((0, 5)) == 0
# ---------------------------------------------------------------------------
# 3. reachable: elevation reduces budget reach
# ---------------------------------------------------------------------------
def test_reachable_climbing_costs_more_cells() -> None:
flat_grid = _make_floor_grid(5, 1)
elev_grid = _make_floor_grid(5, 1, elevation=((0,), (0,), (0,), (0,), (1,)))
flat_reached = flat_grid.reachable((0, 0), 4, frozenset())
elev_reached = elev_grid.reachable((0, 0), 4, frozenset())
assert (4, 0) in flat_reached
assert (4, 0) not in elev_reached
# ---------------------------------------------------------------------------
# 4. _move_path greedy: step cost reflects elevation
# ---------------------------------------------------------------------------
def test_reachable_cumulative_cost_includes_climb() -> None:
elev = ((0, 0, 0), (0, 0, 1))
grid = _make_floor_grid(2, 3, elevation=elev)
reached = grid.reachable((0, 0), 10, frozenset())
assert reached[(1, 2)] == 3
# ---------------------------------------------------------------------------
# 5. High ground: melee +1 from higher elevation
# ---------------------------------------------------------------------------
def _make_elevated_combat_engine(
attacker_pos: tuple[int, int],
target_pos: tuple[int, int],
attacker_elev: int,
target_elev: int,
*,
kind: str = "melee",
) -> tuple[CombatEngine, CombatantState, CombatantState]:
height = max(attacker_pos[0], target_pos[0]) + 1
width = max(attacker_pos[1], target_pos[1]) + 1
elev = tuple(
tuple(
attacker_elev
if (r, c) == attacker_pos
else target_elev
if (r, c) == target_pos
else 0
for c in range(width)
)
for r in range(height)
)
grid = _make_floor_grid(height, width, elevation=elev)
atk = _make_combatant("atk", kind=kind)
tgt = _make_combatant("tgt", ac=1)
a_state = _make_state(atk, "players", attacker_pos)
t_state = _make_state(tgt, "monsters", target_pos)
rng = ScriptedRng([10, 10])
engine = CombatEngine(rng, grid, [a_state, t_state])
return engine, a_state, t_state
def test_high_ground_melee_plus_1() -> None:
engine, a, t = _make_elevated_combat_engine(
(0, 0), (0, 1), attacker_elev=1, target_elev=0
)
result = engine.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total >= 10 + 1 + 2 # d20(10) + high_ground(1) + base(2)
def test_no_high_ground_ranged() -> None:
engine, a, t = _make_elevated_combat_engine(
(0, 0), (0, 3), attacker_elev=1, target_elev=0, kind="ranged"
)
result = engine.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 10 + 2 # d20(10) + base(2), no high ground
def test_no_high_ground_attacker_lower() -> None:
engine, a, t = _make_elevated_combat_engine(
(0, 0), (0, 1), attacker_elev=0, target_elev=1
)
result = engine.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 10 + 2 # d20(10) + base(2), no high ground
def test_no_high_ground_same_elevation() -> None:
engine, a, t = _make_elevated_combat_engine(
(0, 0), (0, 1), attacker_elev=1, target_elev=1
)
result = engine.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 10 + 2 # d20(10) + base(2), same level
# ---------------------------------------------------------------------------
# 6. Grid.from_spec: elevation parsing
# ---------------------------------------------------------------------------
def test_from_spec_with_elevation_layer() -> None:
spec = MapSpec(
name="test",
terrain=("..", ".."),
legend=_LEGEND,
elevation=("01", "2."),
)
grid = Grid.from_spec(spec)
assert grid.elevation_at((0, 0)) == 0
assert grid.elevation_at((0, 1)) == 1
assert grid.elevation_at((1, 0)) == 2
assert grid.elevation_at((1, 1)) == 0
def test_from_spec_without_elevation_layer() -> None:
spec = MapSpec(name="test", terrain=(".",), legend=_LEGEND)
grid = Grid.from_spec(spec)
assert grid.elevation_at((0, 0)) == 0
def test_from_spec_real_map() -> None:
spec = load_map(MAPS_DIR / "test_elevation.yaml")
grid = Grid.from_spec(spec)
assert grid.elevation_at((2, 2)) == 0
assert grid.elevation_at((6, 8)) == 1
# ---------------------------------------------------------------------------
# 7. Integration: combat runs on elevation map
# ---------------------------------------------------------------------------
def test_combat_on_elevation_map_runs() -> None:
spec = load_map(MAPS_DIR / "test_elevation.yaml")
grid = Grid.from_spec(spec)
atk = _make_combatant("p1")
tgt = _make_combatant("m1")
p1 = _make_state(atk, "players", (2, 2))
m1 = _make_state(tgt, "monsters", (6, 6))
rng = SeededRng(1)
engine = CombatEngine(rng, grid, [p1, m1])
result = engine.run()
assert result.rounds >= 1
assert result.winner is not None or result.rounds <= 100
def test_from_spec_loads_test_elevation_map() -> None:
spec = load_map(MAPS_DIR / "test_elevation.yaml")
assert spec.elevation is not None
assert len(spec.elevation) == 10
assert len(spec.elevation[0]) == 12
grid = Grid.from_spec(spec)
assert grid.height == 10
assert grid.width == 12