"""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