feat(grid): apply elevation to movement cost and high ground
This commit is contained in:
@@ -20,8 +20,8 @@ legend:
|
|||||||
|
|
||||||
elevation: |
|
elevation: |
|
||||||
....................
|
....................
|
||||||
..1111..............
|
....................
|
||||||
..1111..............
|
....................
|
||||||
....................
|
....................
|
||||||
....................
|
....................
|
||||||
....................
|
....................
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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_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
|
_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
|
_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
|
_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)
|
_MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1)
|
||||||
_WEAPON_DURABILITY = 10 # Accumulated sunder damage that destroys a weapon.
|
_WEAPON_DURABILITY = 10 # Accumulated sunder damage that destroys a weapon.
|
||||||
@@ -318,6 +319,7 @@ class AttackResult:
|
|||||||
ac: int
|
ac: int
|
||||||
penalty: int = 0
|
penalty: int = 0
|
||||||
flank_bonus: int = 0
|
flank_bonus: int = 0
|
||||||
|
high_ground_bonus: int = 0
|
||||||
base_bonus: int = 0
|
base_bonus: int = 0
|
||||||
|
|
||||||
|
|
||||||
@@ -591,6 +593,10 @@ class CombatEngine:
|
|||||||
for cell_b in cells_b
|
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:
|
def _cells_free(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
|
||||||
"""True if every cell is in bounds, passable, and unoccupied by others."""
|
"""True if every cell is in bounds, passable, and unoccupied by others."""
|
||||||
blocked = self._blocked(mover)
|
blocked = self._blocked(mover)
|
||||||
@@ -842,6 +848,12 @@ class CombatEngine:
|
|||||||
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
|
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
|
||||||
penalty = self.range_penalty(weapon, dist_ft)
|
penalty = self.range_penalty(weapon, dist_ft)
|
||||||
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
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
|
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
||||||
atk_mods = self._stat_modifiers(
|
atk_mods = self._stat_modifiers(
|
||||||
attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
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))
|
atk_mods.append(StatModifier(target="attack", value=-4))
|
||||||
attack_mod = resolve_modifiers(atk_mods)
|
attack_mod = resolve_modifiers(atk_mods)
|
||||||
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
|
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)
|
# Check if target denies DEX to AC (flat-footed)
|
||||||
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
|
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
|
||||||
full_ac_base = (
|
full_ac_base = (
|
||||||
@@ -895,7 +907,9 @@ class CombatEngine:
|
|||||||
if hit:
|
if hit:
|
||||||
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
|
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
|
||||||
confirm = self._rng.d20()
|
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
|
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
||||||
dmg_mods = self._stat_modifiers(
|
dmg_mods = self._stat_modifiers(
|
||||||
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
||||||
@@ -909,7 +923,8 @@ class CombatEngine:
|
|||||||
target.hp -= damage
|
target.hp -= damage
|
||||||
return AttackResult(
|
return AttackResult(
|
||||||
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
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:
|
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
|
||||||
@@ -2521,10 +2536,13 @@ class CombatEngine:
|
|||||||
target_live.damage_taken += result.damage
|
target_live.damage_taken += result.damage
|
||||||
outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS"
|
outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS"
|
||||||
flank_str = f"+{result.flank_bonus}(flank)" if result.flank_bonus else ""
|
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 = (
|
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
|
if result.penalty
|
||||||
else f"{result.base_bonus}{flank_str}"
|
else f"{result.base_bonus}{flank_str}{high_ground_str}"
|
||||||
)
|
)
|
||||||
line = (
|
line = (
|
||||||
f"round {self._current_round} {attacker.combatant.id}: {label} "
|
f"round {self._current_round} {attacker.combatant.id}: {label} "
|
||||||
|
|||||||
@@ -32,17 +32,39 @@ _DIRECTIONS: tuple[Pos, ...] = (
|
|||||||
|
|
||||||
|
|
||||||
class Grid:
|
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._cells = cells
|
||||||
self._height = len(cells)
|
self._height = len(cells)
|
||||||
self._width = len(cells[0]) if cells else 0
|
self._width = len(cells[0]) if cells else 0
|
||||||
|
self._elevation = elevation
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_spec(cls, spec: MapSpec) -> Grid:
|
def from_spec(cls, spec: MapSpec) -> Grid:
|
||||||
cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain)
|
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
|
@property
|
||||||
def height(self) -> int:
|
def height(self) -> int:
|
||||||
@@ -63,6 +85,13 @@ class Grid:
|
|||||||
def passable(self, pos: Pos) -> bool:
|
def passable(self, pos: Pos) -> bool:
|
||||||
return self.in_bounds(pos) and self.terrain_at(pos).move_cost is not None
|
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:
|
def distance(self, a: Pos, b: Pos) -> int:
|
||||||
"""Distance in squares under the 5-10-5-10 diagonal rule."""
|
"""Distance in squares under the 5-10-5-10 diagonal rule."""
|
||||||
d_row = abs(a[0] - b[0])
|
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
|
return max(d_row, d_col) + min(d_row, d_col) // 2
|
||||||
|
|
||||||
def step_cost(self, frm: Pos, to: Pos, diagonals_used: int) -> int:
|
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
|
cost = self.terrain_at(to).move_cost
|
||||||
if cost is None:
|
if cost is None:
|
||||||
msg = f"cannot step into impassable square {to}"
|
msg = f"cannot step into impassable square {to}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
is_diagonal = frm[0] != to[0] and frm[1] != to[1]
|
is_diagonal = frm[0] != to[0] and frm[1] != to[1]
|
||||||
if not is_diagonal:
|
if not is_diagonal:
|
||||||
return cost
|
return cost + max(0, self.elevation_at(to) - self.elevation_at(frm))
|
||||||
if cost >= _DIFFICULT_TERRAIN_COST:
|
if cost >= _DIFFICULT_TERRAIN_COST:
|
||||||
return 3
|
return 3 + max(0, self.elevation_at(to) - self.elevation_at(frm))
|
||||||
return 1 + (diagonals_used % 2)
|
return 1 + (diagonals_used % 2) + max(
|
||||||
|
0, self.elevation_at(to) - self.elevation_at(frm)
|
||||||
|
)
|
||||||
|
|
||||||
def diagonal_allowed(self, frm: Pos, to: Pos) -> bool:
|
def diagonal_allowed(self, frm: Pos, to: Pos) -> bool:
|
||||||
flank_a = (frm[0], to[1])
|
flank_a = (frm[0], to[1])
|
||||||
|
|||||||
@@ -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
|
||||||
+5
-1
@@ -46,7 +46,11 @@ def test_sample_arena_loads() -> None:
|
|||||||
def test_sample_arena_zones_and_elevation() -> None:
|
def test_sample_arena_zones_and_elevation() -> None:
|
||||||
spec = load_map(MAPS_DIR / "sample_arena.yaml")
|
spec = load_map(MAPS_DIR / "sample_arena.yaml")
|
||||||
assert spec.elevation is not None
|
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")
|
cells_a = zone_cells(spec, "A")
|
||||||
assert cells_a == [
|
assert cells_a == [
|
||||||
(1, 1), (1, 2), (1, 3), (1, 4),
|
(1, 1), (1, 2), (1, 3), (1, 4),
|
||||||
|
|||||||
Reference in New Issue
Block a user