diff --git a/data/maps/sample_arena.yaml b/data/maps/sample_arena.yaml index 860eb4e..02fb339 100644 --- a/data/maps/sample_arena.yaml +++ b/data/maps/sample_arena.yaml @@ -20,8 +20,8 @@ legend: elevation: | .................... - ..1111.............. - ..1111.............. + .................... + .................... .................... .................... .................... diff --git a/data/maps/test_elevation.yaml b/data/maps/test_elevation.yaml new file mode 100644 index 0000000..c4df899 --- /dev/null +++ b/data/maps/test_elevation.yaml @@ -0,0 +1,50 @@ +name: "Elevation Test" +square_size_ft: 5 + +terrain: | + ############ + #..........# + #..........# + #..........# + #..........# + #..........# + #..........# + #..........# + #..........# + ############ + +legend: + "#": { type: wall, move_cost: null, blocks_los: true } + ".": { type: floor, move_cost: 1 } + +elevation: | + ............ + ............ + ..0000...... + ..0000...... + ............ + ............ + ......1111.. + ......1111.. + ............ + ............ + +zones: | + ............ + ............ + ..AAAA...... + ..AAAA...... + ............ + ............ + ......BBBB.. + ......BBBB.. + ............ + ............ + +markers: + ground_center: [5, 5] + platform_center: [6, 8] + +deployment: + players: A + monsters: B diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 0f59cae..16e044f 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -121,6 +121,7 @@ _RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the fir _MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments _MAX_TOUCH_RANGE_INCREMENTS = 5 # PF1e: thrown/firearm touch weapons shoot up to 5 increments _FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side +_HIGH_GROUND_BONUS = 1 # PF1e: +1 melee attack when attacker stands higher than target _ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous _MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1) _WEAPON_DURABILITY = 10 # Accumulated sunder damage that destroys a weapon. @@ -318,6 +319,7 @@ class AttackResult: ac: int penalty: int = 0 flank_bonus: int = 0 + high_ground_bonus: int = 0 base_bonus: int = 0 @@ -591,6 +593,10 @@ class CombatEngine: for cell_b in cells_b ) + def _elevation(self, state: CombatantState) -> int: + """Height of the creature's anchor cell (0 when no elevation layer).""" + return self._grid.elevation_at(state.pos) + def _cells_free(self, cells: Iterable[Pos], mover: CombatantState) -> bool: """True if every cell is in bounds, passable, and unoccupied by others.""" blocked = self._blocked(mover) @@ -842,6 +848,12 @@ class CombatEngine: dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT penalty = self.range_penalty(weapon, dist_ft) flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0 + high_ground = ( + _HIGH_GROUND_BONUS + if weapon.kind == "melee" + and self._elevation(attacker) > self._elevation(target) + else 0 + ) base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus atk_mods = self._stat_modifiers( attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft @@ -850,7 +862,7 @@ class CombatEngine: atk_mods.append(StatModifier(target="attack", value=-4)) attack_mod = resolve_modifiers(atk_mods) pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 - total = roll + base_bonus + penalty + flank + attack_mod - pa_amt + total = roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt # Check if target denies DEX to AC (flat-footed) denies_dex = self.has_condition_flag(target, "denies_dex_to_ac") full_ac_base = ( @@ -895,7 +907,9 @@ class CombatEngine: if hit: if roll != _NATURAL_ONE and roll >= weapon.crit_range: confirm = self._rng.d20() - confirm_total = confirm + base_bonus + penalty + flank + attack_mod - pa_amt + confirm_total = ( + confirm + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt + ) crit = confirm != _NATURAL_ONE and confirm_total >= ac dmg_mods = self._stat_modifiers( attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft @@ -909,7 +923,8 @@ class CombatEngine: target.hp -= damage return AttackResult( hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, - penalty=penalty, flank_bonus=flank, base_bonus=base_bonus, + penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground, + base_bonus=base_bonus, ) def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int: @@ -2521,10 +2536,13 @@ class CombatEngine: target_live.damage_taken += result.damage outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS" flank_str = f"+{result.flank_bonus}(flank)" if result.flank_bonus else "" + high_ground_str = ( + f"+{result.high_ground_bonus}(high)" if result.high_ground_bonus else "" + ) bonus_str = ( - f"{result.base_bonus}{result.penalty:+d}{flank_str}" + f"{result.base_bonus}{result.penalty:+d}{flank_str}{high_ground_str}" if result.penalty - else f"{result.base_bonus}{flank_str}" + else f"{result.base_bonus}{flank_str}{high_ground_str}" ) line = ( f"round {self._current_round} {attacker.combatant.id}: {label} " diff --git a/src/pf1e_simulator/grid.py b/src/pf1e_simulator/grid.py index 0b52b7d..b986ee2 100644 --- a/src/pf1e_simulator/grid.py +++ b/src/pf1e_simulator/grid.py @@ -32,17 +32,39 @@ _DIRECTIONS: tuple[Pos, ...] = ( class Grid: - """Terrain view of a MapSpec with movement and distance queries.""" + """Terrain view of a MapSpec with movement and distance queries. - def __init__(self, cells: tuple[tuple[TerrainType, ...], ...]) -> None: + An optional elevation layer stores a height per cell; climbing one level + costs one extra square of movement (PF1e), descending is free. + """ + + def __init__( + self, + cells: tuple[tuple[TerrainType, ...], ...], + elevation: tuple[tuple[int, ...], ...] | None = None, + ) -> None: self._cells = cells self._height = len(cells) self._width = len(cells[0]) if cells else 0 + self._elevation = elevation @classmethod def from_spec(cls, spec: MapSpec) -> Grid: cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain) - return cls(cells) + elevation = None + if spec.elevation is not None: + height = len(cells) + width = len(cells[0]) if cells else 0 + if len(spec.elevation) != height or ( + height and len(spec.elevation[0]) != width + ): + msg = "elevation layer dimensions do not match terrain" + raise ValueError(msg) + elevation = tuple( + tuple(0 if ch == "." else int(ch) for ch in row) + for row in spec.elevation + ) + return cls(cells, elevation=elevation) @property def height(self) -> int: @@ -63,6 +85,13 @@ class Grid: def passable(self, pos: Pos) -> bool: return self.in_bounds(pos) and self.terrain_at(pos).move_cost is not None + def elevation_at(self, pos: Pos) -> int: + """Height of a cell; 0 when no elevation layer or out of bounds.""" + if self._elevation is None or not self.in_bounds(pos): + return 0 + row, col = pos + return self._elevation[row][col] + def distance(self, a: Pos, b: Pos) -> int: """Distance in squares under the 5-10-5-10 diagonal rule.""" d_row = abs(a[0] - b[0]) @@ -70,17 +99,23 @@ class Grid: return max(d_row, d_col) + min(d_row, d_col) // 2 def step_cost(self, frm: Pos, to: Pos, diagonals_used: int) -> int: - """Cost in squares of one step; diagonals_used is the count so far.""" + """Cost in squares of one step; diagonals_used is the count so far. + + Climbing one elevation level adds one square per level; descending is + free and never reduces the terrain cost. + """ cost = self.terrain_at(to).move_cost if cost is None: msg = f"cannot step into impassable square {to}" raise ValueError(msg) is_diagonal = frm[0] != to[0] and frm[1] != to[1] if not is_diagonal: - return cost + return cost + max(0, self.elevation_at(to) - self.elevation_at(frm)) if cost >= _DIFFICULT_TERRAIN_COST: - return 3 - return 1 + (diagonals_used % 2) + return 3 + max(0, self.elevation_at(to) - self.elevation_at(frm)) + return 1 + (diagonals_used % 2) + max( + 0, self.elevation_at(to) - self.elevation_at(frm) + ) def diagonal_allowed(self, frm: Pos, to: Pos) -> bool: flank_a = (frm[0], to[1]) diff --git a/tests/test_elevation.py b/tests/test_elevation.py new file mode 100644 index 0000000..6deff86 --- /dev/null +++ b/tests/test_elevation.py @@ -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 diff --git a/tests/test_map.py b/tests/test_map.py index 63a110e..b10eef9 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -46,7 +46,11 @@ def test_sample_arena_loads() -> None: def test_sample_arena_zones_and_elevation() -> None: spec = load_map(MAPS_DIR / "sample_arena.yaml") assert spec.elevation is not None - assert spec.elevation[1][2] == "1" + # Flattened to '.' — platform removed so elevation has no mechanical effect + assert spec.elevation[1][2] == "." + assert all( + ch == "." for row in spec.elevation for ch in row + ) cells_a = zone_cells(spec, "A") assert cells_a == [ (1, 1), (1, 2), (1, 3), (1, 4),