feat(grid): apply elevation to movement cost and high ground
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user