feat(rules): 3D flight over ground terrain - airborne creatures (fly >= land, V4 budget from fly speed) move on Grid.airborne_view: ground difficult terrain costs 1 and cells impassable to walkers (pits, water, rubble, low obstacles) are crossable at cost 1, EXCEPT blocks_los cells (solid barriers, assumed full height) which still stop flight; run/charge/5-foot-step gates and diagonal corner rule follow the same view (flying past a pit is legal, past a wall corner is not); elevation climb cost still applies (step heights, not wall heights - documented); line of effect, cover and high ground use the unchanged real grid so combat geometry is unaffected by flight (SRD Movement, Position and Distance: flying creatures not hampered by difficult terrain, able to avoid most obstacles), 11 tests

This commit is contained in:
2026-09-20 22:21:40 +02:00
parent 1257bb3849
commit 2ae86ab29f
3 changed files with 461 additions and 66 deletions
+140 -57
View File
@@ -55,8 +55,13 @@ Phase 0 documented deviations from PF1e (conventions):
- Movement: a move action travels up to the combatant's speed in cells along
the true shortest path (Dijkstra cost field from the target, 5-10-5
diagonals), stopping adjacent to the nearest enemy; ties keep delta order.
The speed budget is the best of land and fly speed (V4); flying over
obstacles or difficult terrain is NOT modeled (grid costs still apply).
The speed budget is the best of land and fly speed (V4). A creature whose
budget comes from its fly speed (fly >= land) is AIRBORNE: it ignores
difficult-terrain costs and crosses ground cells impassable to walkers
(pits, rubble, water), except cells that block line of effect (solid
barriers, assumed full height) — SRD "Movement, Position, and Distance":
"Flying and incorporeal creatures are not hampered by difficult terrain" /
"are able to avoid most obstacles". No fly maneuverability, no 3D flight.
- Line of effect gates all attacks: a target fully behind blocking terrain
cannot be attacked, and the policy moves to gain sight instead.
- Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
@@ -774,15 +779,35 @@ def _melee_reach_zone_cells(state: CombatantState, weapon: AttackSpec) -> tuple[
def _speed_ft(state: CombatantState) -> int:
"""Movement budget in feet: the best of the combatant's land and fly speed.
Simplification (V4): a creature that has a fly speed is assumed to use it;
flying OVER obstacles or difficult terrain is NOT modeled — the grid's
passability and terrain costs still apply to the whole budget. No
fly maneuverability, no 3D flight. Swim/climb/burrow speeds do not exist
in the model (fly only).
Simplification (V4): a creature that has a fly speed is assumed to use it
when its fly speed is at least its land speed (see ``_is_airborne``). The
budget itself is land-or-fly; an airborne creature additionally moves over
ground terrain at cost 1 (``Grid.airborne_view``). No fly maneuverability,
no 3D flight. Swim/climb/burrow speeds do not exist in the model (fly only).
"""
return max(state.combatant.speed_land_ft, state.combatant.speed_fly_ft or 0)
def _is_airborne(state: CombatantState) -> bool:
"""True when the creature's movement budget comes from its fly speed.
The engine's V4 assumption is that a creature with a fly speed uses it
(budget = max(land, fly)); the creature is AIRBORNE — flying over the
ground, so difficult terrain does not hamper it and it avoids the
obstacles that merely block walkers (SRD "Movement, Position, and
Distance": "Flying and incorporeal creatures are not hampered by difficult
terrain"; "Flying and incorporeal creatures are able to avoid most
obstacles") — exactly when the fly speed is the source of that budget,
i.e. ``speed_fly_ft >= speed_land_ft``. A creature whose fly speed is
slower than its land speed walks (the budget is the land speed) and pays
ground terrain costs normally. There is no explicit altitude flag: the
model has no 3D position, so "airborne" is derived from the speeds alone
(documented decision).
"""
fly = state.combatant.speed_fly_ft
return fly is not None and fly >= state.combatant.speed_land_ft
def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
r0, c0 = start
@@ -1598,6 +1623,7 @@ class CombatEngine:
raise ValueError(msg)
self._rng = rng
self._grid = grid
self._airborne_grid = grid.airborne_view()
self._states = states
self._round_cap = round_cap
if llm_policy is not None:
@@ -3022,11 +3048,25 @@ class CombatEngine:
"""Height of the creature's anchor cell (0 when no elevation layer)."""
return self._grid.elevation_at(state.pos)
def _movement_grid(self, state: CombatantState) -> Grid:
"""The terrain grid the mover walks on: the air view when airborne.
An airborne creature prices and gates every movement action (move,
run, charge, withdraw, 5-foot step) on ``Grid.airborne_view``: ground
difficult terrain costs 1 and impassable non-barrier cells (pits,
water, rubble) are crossable; ``blocks_los`` cells (solid barriers,
assumed full height) stay impassable. Line of effect, cover and
high ground always use the real ``_grid`` — the air view preserves
their terrain flags, so combat geometry is unaffected by flight.
"""
return self._airborne_grid if _is_airborne(state) else self._grid
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)
grid = self._movement_grid(mover)
return all(
self._grid.passable(cell) and cell not in blocked for cell in cells
grid.passable(cell) and cell not in blocked for cell in cells
)
def _cells_walkable(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
@@ -3037,8 +3077,9 @@ class CombatEngine:
though the movement may not END there (``_cells_free`` gates that).
"""
hostile = self._blocked_hostile(mover)
grid = self._movement_grid(mover)
return all(
self._grid.passable(cell) and cell not in hostile for cell in cells
grid.passable(cell) and cell not in hostile for cell in cells
)
def _footprint_at(self, anchor: Pos, state: CombatantState) -> frozenset[Pos]:
@@ -4420,16 +4461,18 @@ class CombatEngine:
(API publique, lecture pour politique LLM.) Same cost model as
``Grid.reachable``: 5-10-5 diagonals, terrain, elevation, blocked
creatures excluded. ``budget`` is in feet; None = full field.
creatures excluded. An airborne mover prices the field on the air
view (``_movement_grid``). ``budget`` is in feet; None = full field.
"""
blocked = self._blocked(state)
grid = self._movement_grid(state)
if budget is None:
field = self._grid.reachable(state.pos, None, blocked)
field = grid.reachable(state.pos, None, blocked)
else:
budget_cells = budget // _SQUARE_FT
if budget_cells < 1:
return {}
field = self._grid.reachable(state.pos, budget_cells, blocked)
field = grid.reachable(state.pos, budget_cells, blocked)
return {pos: cost * _SQUARE_FT for pos, cost in field.items()}
def movement_cost_ft(self, state: CombatantState, target: CombatantState) -> int | None:
@@ -4440,10 +4483,11 @@ class CombatEngine:
``_move_path``: a full Dijkstra field from the target's anchor (other
active states' cells blocked), then the cheapest field value over
``state``'s footprint, compared against the combatant's effective
(land-or-fly) speed. Read-only: never mutates state nor consumes RNG.
(land-or-fly) speed. An airborne ``state`` prices the field on the air
view. Read-only: never mutates state nor consumes RNG.
"""
blocked = self._blocked(state, target)
field = self._grid.reachable(target.pos, None, blocked)
field = self._movement_grid(state).reachable(target.pos, None, blocked)
cost_cells = self._footprint_cost(state.occupied_cells(), field)
if cost_cells is None:
return None
@@ -4840,12 +4884,12 @@ class CombatEngine:
return ChargeResult(weapon=weapon, distance_ft=distance_ft, reason=None)
return ChargeResult(weapon=None, distance_ft=0, reason="no_charge_path")
def _path_cost_5105(self, path: list[Pos]) -> int:
"""Total 5-10-5 movement cost of a path (diagonal parity threaded)."""
def _path_cost_5105(self, path: list[Pos], grid: Grid) -> int:
"""Total 5-10-5 movement cost of a path on ``grid`` (parity threaded)."""
total = 0
diagonals_used = 0
for frm, to in pairwise(path):
total += self._grid.step_cost(frm, to, diagonals_used)
total += grid.step_cost(frm, to, diagonals_used)
if frm[0] != to[0] and frm[1] != to[1]:
diagonals_used += 1
return total
@@ -4869,8 +4913,11 @@ class CombatEngine:
Ties break toward lower distance from state.
"""
target_cells = target.occupied_cells()
grid = self._movement_grid(state)
candidates: list[tuple[int, Pos]] = []
for end in self._charge_candidates(target, reach_cells, blocked, state.pos):
for end in self._charge_candidates(
target, reach_cells, blocked, state.pos, grid
):
footprint = self._footprint_at(end, state)
if not self._cells_free(footprint, state):
continue
@@ -4888,7 +4935,7 @@ class CombatEngine:
continue
# The charge budget is a movement budget: count it in 5-10-5
# squares (diagonal parity threaded), not raw cells.
if self._path_cost_5105(path) > max_cells:
if self._path_cost_5105(path, grid) > max_cells:
continue
candidates.append((len(path), end))
if not candidates:
@@ -4902,8 +4949,13 @@ class CombatEngine:
reach_cells: int,
blocked: frozenset[Pos],
start: Pos,
grid: Grid,
) -> list[Pos]:
"""Anchors within reach of some target cell, passable, unoccupied, not the start."""
"""Anchors within reach of some target cell, passable, unoccupied, not the start.
``grid`` is the charger's movement grid (the air view when airborne:
an airborne charger ends its charge over cells walkers cannot enter).
"""
result: set[Pos] = set()
for tr, tc in target.occupied_cells():
for dr in range(-reach_cells, reach_cells + 1):
@@ -4915,7 +4967,7 @@ class CombatEngine:
end = (tr + dr, tc + dc)
if end == start:
continue
if not self._grid.in_bounds(end) or not self._grid.passable(end):
if not grid.in_bounds(end) or not grid.passable(end):
continue
if end in blocked:
continue
@@ -4939,19 +4991,24 @@ class CombatEngine:
below. Difficult terrain is tested over the WHOLE footprint, not
just the anchor cell (SRD "Movement, Position and Distance": "You
can't run or charge across difficult terrain" - a 2x2 creature with
one trailing square in rubble cannot charge).
one trailing square in rubble cannot charge). An AIRBORNE charger is
not hampered by difficult terrain and avoids ground obstacles, so it
walks these gates on the air view (SRD "Movement, Position, and
Distance": "Flying and incorporeal creatures are not hampered by
difficult terrain"); solid barriers still stop the charge.
"""
grid = self._movement_grid(state)
prev = state.pos
for pos in path:
footprint = self._footprint_at(pos, state)
if not self._cells_free(footprint, state):
return False
if self._footprint_touches_difficult(footprint):
if self._footprint_touches_difficult(state, footprint):
return False
if (
prev[0] != pos[0]
and prev[1] != pos[1]
and not self._grid.diagonal_allowed(prev, pos)
and not grid.diagonal_allowed(prev, pos)
):
return False
prev = pos
@@ -9349,12 +9406,15 @@ class CombatEngine:
# SRD "Take 5-Foot Step": "You can only take a 5-foot-step if your
# movement isn't hampered by difficult terrain or darkness." — a step
# out of OR into a difficult cell is blocked (darkness not modeled).
if self._footprint_touches_difficult(self._footprint_at(state.pos, state)):
# An airborne creature's movement is not hampered by difficult
# terrain (SRD "Movement, Position, and Distance"), so the gate
# passes for it.
if self._footprint_touches_difficult(state, self._footprint_at(state.pos, state)):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)")
return
path = self._move_path(state, target)
start = state.pos
if self._footprint_touches_difficult(self._footprint_at(path[0], state)):
if self._footprint_touches_difficult(state, self._footprint_at(path[0], state)):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)")
return
state.pos = path[0]
@@ -9376,16 +9436,19 @@ class CombatEngine:
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
adjacent = self._grid.distance(state.pos, destination) == 1
free = self._cells_free(self._footprint_at(destination, state), state)
grid = self._movement_grid(state)
# PF1e (SRD Measuring Distance): "You can't move diagonally past a
# corner (even by taking a 5-foot step)."
diagonal = state.pos[0] != destination[0] and state.pos[1] != destination[1]
corner_clear = not diagonal or self._grid.diagonal_allowed(state.pos, destination)
corner_clear = not diagonal or grid.diagonal_allowed(state.pos, destination)
# SRD "Take 5-Foot Step": "You can only take a 5-foot-step if your
# movement isn't hampered by difficult terrain or darkness." — a step
# out of OR into a difficult cell is blocked (darkness not modeled).
# out of OR into a difficult cell is blocked (darkness not modeled);
# flight is not hampered by ground terrain, so the gate passes for an
# airborne creature.
step_clear = not (
self._footprint_touches_difficult(self._footprint_at(state.pos, state))
or self._footprint_touches_difficult(self._footprint_at(destination, state))
self._footprint_touches_difficult(state, self._footprint_at(state.pos, state))
or self._footprint_touches_difficult(state, self._footprint_at(destination, state))
)
if not adjacent or not free or not corner_clear or not step_clear:
self._log(
@@ -9497,22 +9560,23 @@ class CombatEngine:
if not self._cells_free(self._footprint_at(destination, state), state):
return False
if avoid_difficult and self._footprint_touches_difficult(
self._footprint_at(destination, state)
state, self._footprint_at(destination, state)
):
return False
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
grid = self._movement_grid(state)
if avoid_difficult:
blocked = blocked | self._difficult_cells()
field = self._grid.reachable(state.pos, speed_cells, blocked)
blocked = blocked | self._difficult_cells(grid)
field = grid.reachable(state.pos, speed_cells, blocked)
return destination in field
def _difficult_cells(self) -> frozenset[Pos]:
"""Every passable cell on the grid whose terrain costs more than one."""
def _difficult_cells(self, grid: Grid) -> frozenset[Pos]:
"""Every passable cell of ``grid`` whose terrain costs more than one."""
return frozenset(
(row, col)
for row in range(self._grid.height)
for col in range(self._grid.width)
if self._is_difficult_cell((row, col))
for row in range(grid.height)
for col in range(grid.width)
if self._is_difficult_cell(grid, (row, col))
)
def _walk_steps(
@@ -9595,7 +9659,7 @@ class CombatEngine:
if speed_cells <= 0:
return []
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
to_target = self._grid.reachable(anchor, None, blocked)
to_target = self._movement_grid(state).reachable(anchor, None, blocked)
if self._footprint_cost(state.occupied_cells(), to_target) is None:
return []
path: list[Pos] = []
@@ -9642,7 +9706,11 @@ class CombatEngine:
Through a Square"), which is crossed with an entry AoO and never
ended on. Friendly squares
are still crossed (SRD Movement, conformance L5) but never ended on —
the tail is trimmed like ``_descend_path``.
the tail is trimmed like ``_descend_path``. An AIRBORNE runner prices
the line on the air view: ground difficult terrain and impassable
non-barrier cells do not stop it (SRD "Movement, Position, and
Distance": "Flying and incorporeal creatures are not hampered by
difficult terrain").
"""
speed_mult = self._get_speed_mult(state)
budget = int(
@@ -9650,6 +9718,7 @@ class CombatEngine:
)
if budget <= 0:
return []
grid = self._movement_grid(state)
path: list[Pos] = []
current = state.pos
diagonals_used = 0
@@ -9661,15 +9730,15 @@ class CombatEngine:
if (
current[0] != nxt[0]
and current[1] != nxt[1]
and not self._grid.diagonal_allowed(current, nxt)
and not grid.diagonal_allowed(current, nxt)
):
break
footprint = self._footprint_at(nxt, state)
if self._footprint_touches_difficult(footprint):
if self._footprint_touches_difficult(state, footprint):
break
if not self._cells_walkable(footprint, state):
break
step = self._grid.step_cost(current, nxt, diagonals_used)
step = grid.step_cost(current, nxt, diagonals_used)
if step > budget:
break
path.append(nxt)
@@ -9698,6 +9767,7 @@ class CombatEngine:
return None
best: tuple[int, int, Pos] | None = None
row, col = current
grid = self._movement_grid(state)
fits = self._cells_walkable if friendly_passable else self._cells_free
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
@@ -9705,12 +9775,12 @@ class CombatEngine:
# a corner (even by taking a 5-foot step)" — same corner rule the
# explicit step (``_step5_to``) and flee (``_best_flee_step``)
# paths already enforce.
if not self._grid.diagonal_allowed(current, nxt):
if not grid.diagonal_allowed(current, nxt):
continue
footprint = self._footprint_at(nxt, state)
if not fits(footprint, state):
continue
if avoid_difficult and self._footprint_touches_difficult(footprint):
if avoid_difficult and self._footprint_touches_difficult(state, footprint):
continue
nxt_cost = self._footprint_cost(footprint, to_target)
if nxt_cost is None or nxt_cost >= current_cost:
@@ -9766,9 +9836,17 @@ class CombatEngine:
frontier.append((nxt, cost))
return False
def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool:
"""True if any footprint cell is difficult terrain (move cost > 1)."""
return any(self._is_difficult_cell(cell) for cell in footprint)
def _footprint_touches_difficult(
self, state: CombatantState, footprint: frozenset[Pos]
) -> bool:
"""True if any footprint cell is difficult terrain (move cost > 1).
Priced on the mover's movement grid: an airborne creature is not
hampered by difficult terrain (SRD "Movement, Position, and
Distance"), so no cell is difficult for it.
"""
grid = self._movement_grid(state)
return any(self._is_difficult_cell(grid, cell) for cell in footprint)
def _footprint_step_cost(
self, state: CombatantState, frm: Pos, to: Pos, diagonals_used: int
@@ -9783,22 +9861,26 @@ class CombatEngine:
at ``to`` (the 5-10-5 + elevation formula stays in ``grid.py``). For a
1x1 creature the footprint is the anchor cell, so this is exactly
``grid.step_cost`` - bit-identical to the old anchor-only pricing.
The pricing grid is the mover's movement grid: every cell costs 1 to
an airborne creature (SRD "Movement, Position, and Distance": flying
creatures are not hampered by difficult terrain).
"""
grid = self._movement_grid(state)
footprint = self._footprint_at(to, state)
hardest = 1
for cell in footprint:
if not self._grid.in_bounds(cell):
if not grid.in_bounds(cell):
continue
cost = self._grid.terrain_at(cell).move_cost
cost = grid.terrain_at(cell).move_cost
if cost is not None and cost > hardest:
hardest = cost
return self._grid.step_cost_with_terrain(frm, to, diagonals_used, hardest)
return grid.step_cost_with_terrain(frm, to, diagonals_used, hardest)
def _is_difficult_cell(self, pos: Pos) -> bool:
"""True for a passable cell whose terrain costs more than one square."""
if not self._grid.in_bounds(pos):
def _is_difficult_cell(self, grid: Grid, pos: Pos) -> bool:
"""True for a passable cell of ``grid`` whose terrain costs more than one."""
if not grid.in_bounds(pos):
return False
terrain = self._grid.terrain_at(pos)
terrain = grid.terrain_at(pos)
return terrain.move_cost is not None and terrain.move_cost > 1
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
@@ -9819,7 +9901,7 @@ class CombatEngine:
if max_cells <= 0:
return []
blocked = self._blocked(state)
from_threat = self._grid.reachable(threat.pos, None, blocked)
from_threat = self._movement_grid(state).reachable(threat.pos, None, blocked)
if self._footprint_cost(state.occupied_cells(), from_threat) is None:
return []
path: list[Pos] = []
@@ -9854,12 +9936,13 @@ class CombatEngine:
return None
best: tuple[int, int, Pos] | None = None
row, col = current
grid = self._movement_grid(state)
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
footprint = self._footprint_at(nxt, state)
if not self._cells_free(footprint, state):
continue
if not self._grid.diagonal_allowed(current, nxt):
if not grid.diagonal_allowed(current, nxt):
continue
nxt_cost = self._footprint_cost(footprint, from_threat)
if nxt_cost is None or nxt_cost <= current_cost:
+36
View File
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
from pf1e_simulator.map import MapSpec, Pos, TerrainType
_DIFFICULT_TERRAIN_COST = 2 # rubble, water, etc. count as 2 squares (PF1e)
_AIRBORNE_TERRAIN_COST = 1 # flying is not hampered by ground terrain (PF1e)
_DIRECTIONS: tuple[Pos, ...] = (
(-1, -1),
@@ -48,6 +49,41 @@ class Grid:
self._width = len(cells[0]) if cells else 0
self._elevation = elevation
@staticmethod
def _airborne_terrain(terrain: TerrainType) -> TerrainType:
"""The same square seen from the air (PF1e flight over ground terrain).
SRD "Movement, Position, and Distance" (Terrain and Obstacles):
"Flying and incorporeal creatures are not hampered by difficult
terrain" and "Flying and incorporeal creatures are able to avoid most
obstacles" — so a cell that only slows walking (move_cost > 1) or
stops it (pits, rubble, water: move_cost None) becomes a plain
cost-1 cell in the air view. The exception is a SOLID BARRIER
(`blocks_los`, the engine's encoding of CRB "Aiming a Spell — Line of
Effect": "A line of effect is canceled by a solid barrier"), which is
assumed full height and still stops flight. Cover flags and the
terrain name are preserved; only the walking cost is normalized.
"""
if terrain.blocks_los:
return terrain
cost = terrain.move_cost
if cost is None or cost > _AIRBORNE_TERRAIN_COST:
return terrain.model_copy(update={"move_cost": _AIRBORNE_TERRAIN_COST})
return terrain
def airborne_view(self) -> Grid:
"""A terrain view for airborne creatures (see ``_airborne_terrain``).
Elevation is preserved: the layer stores STEP heights of the ground,
not wall or cliff heights, so the climbing cost still applies to
flight (documented simplification — true 3D flight is not modeled).
"""
cells = tuple(
tuple(self._airborne_terrain(terrain) for terrain in row)
for row in self._cells
)
return Grid(cells, elevation=self._elevation)
@classmethod
def from_spec(cls, spec: MapSpec) -> Grid:
cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain)
+283 -7
View File
@@ -1,14 +1,18 @@
"""V4: movement uses the best of land and fly speed.
"""V4: movement uses the best of land and fly speed + 3D flight over terrain.
The engine budgets every movement action (move, 5-foot step toward a target,
charge at 2x, withdraw at 2x, run at 4x) from ``_speed_ft(state)`` — the
maximum of the creature's ``speed_land_ft`` and ``speed_fly_ft`` (a missing
fly speed counts as 0, so a land-only creature is unchanged). Simplification:
a creature that has a fly speed is assumed to use it, but flying OVER
obstacles or difficult terrain is NOT modeled — the grid's passability and
terrain costs still apply to the whole budget. The observation snapshot adds
an additive ``speed_effective_ft`` key; ``speed_land_ft``/``speed_fly_ft`` are
kept as-is.
fly speed counts as 0, so a land-only creature is unchanged). A creature
whose budget comes from its fly speed (fly >= land) is AIRBORNE
(``_is_airborne``): it moves on ``Grid.airborne_view``, where ground difficult
terrain costs 1 and cells impassable to walkers (pits, water, rubble) are
crossable — SRD "Movement, Position, and Distance": "Flying and incorporeal
creatures are not hampered by difficult terrain"; "Flying and incorporeal
creatures are able to avoid most obstacles". Cells that block line of effect
(``blocks_los`` — solid barriers, assumed full height) still stop flight and
still block attacks. The observation snapshot adds an additive
``speed_effective_ft`` key; ``speed_land_ft``/``speed_fly_ft`` are kept as-is.
"""
from __future__ import annotations
@@ -77,6 +81,45 @@ def _corridor(cols: int) -> Grid:
return Grid.from_spec(spec)
_LEGEND = {
".": TerrainType(type="floor", move_cost=1),
"T": TerrainType(type="rubble", move_cost=2),
"P": TerrainType(type="pit", move_cost=None),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
def _terrain_grid(rows: tuple[str, ...]) -> Grid:
"""A grid from ASCII rows over the shared ``_LEGEND`` terrain types."""
spec = MapSpec(name="terrain", terrain=rows, legend=_LEGEND)
return Grid.from_spec(spec)
def _run_move_on(
*, land: int, fly: int | None, destination: tuple[int, int], grid: Grid,
) -> CombatantState:
"""Hero acts first and takes one ``move`` to ``destination`` on ``grid``.
The foe idles far to the right (never threatens). Returns the hero state.
"""
cols = grid.width
hero = make_state(
_combatant("hero", land=land, fly=fly, initiative_mod=2), "players", (0, 0),
)
foe = make_state(_combatant("foe", land=30), "monsters", (0, cols - 1))
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", destination=destination),)
return _WAIT
eng = CombatEngine(
ScriptedRng([10, 5]), grid, [hero, foe], policy=policy, round_cap=1,
)
eng.run()
return eng._states[0]
def _run_move(
*, land: int, fly: int | None, destination: tuple[int, int], cols: int,
) -> CombatantState:
@@ -220,3 +263,236 @@ def test_fly_lower_than_land_uses_land() -> None:
assert mover.pos == (0, 6)
mover = _run_move(land=30, fly=10, destination=(0, 7), cols=12)
assert mover.pos == (0, 0)
# ── 3D flight over ground terrain (airborne creatures) ────────────────────────
#
# SRD "Movement, Position, and Distance" (Terrain and Obstacles): "Flying and
# incorporeal creatures are not hampered by difficult terrain" and "Flying and
# incorporeal creatures are able to avoid most obstacles." An airborne creature
# (fly >= land, ``_is_airborne``) moves on ``Grid.airborne_view``: ground
# difficult terrain costs 1 and cells impassable to walkers (pits) are
# crossable, EXCEPT cells that block line of effect (solid barriers, assumed
# full height) which still stop flight. Walkers are bit-identical. The foe sits
# two rows below the hero's lane so it never threatens the row being crossed.
_LEGEND = {
".": TerrainType(type="floor", move_cost=1),
"T": TerrainType(type="rubble", move_cost=2),
"P": TerrainType(type="pit", move_cost=None),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
def _lane_grid(lane: str) -> Grid:
"""A 3-row grid: the terrain ``lane`` on row 0, floor rows below."""
width = len(lane)
rows = (lane, "." * width, "." * width)
spec = MapSpec(name="lane", terrain=rows, legend=_LEGEND)
return Grid.from_spec(spec)
def _move_on_lane(*, land: int, fly: int | None, lane: str,
destination: tuple[int, int]) -> tuple[int, int]:
"""Hero (row 0, col 0) acts first and takes one ``move`` to ``destination``.
The foe idles at (2, 0), two rows below and never threatening. Returns the
hero's final position.
"""
hero = make_state(
_combatant("hero", land=land, fly=fly, initiative_mod=2), "players", (0, 0),
)
foe = make_state(_combatant("foe", land=30), "monsters", (2, 0))
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", destination=destination),)
return _WAIT
eng = CombatEngine(
ScriptedRng([10, 5]), _lane_grid(lane), [hero, foe],
policy=policy, round_cap=1,
)
eng.run()
return eng._states[0].pos
def test_flyer_crosses_rubble_at_normal_cost() -> None:
# Rubble at cols 4-5: the flyer prices every cell at 1, so (0, 9) costs
# 9 cells and is within the 16-cell (fly 80) budget.
assert _move_on_lane(land=30, fly=80, lane="....TT....", destination=(0, 9)) == (0, 9)
def test_walker_still_pays_for_rubble() -> None:
# Same lane, same 10-cell span: a land-30 walker pays 3 + 2*2 + 4 = 11
# cells for (0, 9), beyond its 6-cell budget, so it is rejected in place.
assert _move_on_lane(land=30, fly=None, lane="....TT....", destination=(0, 9)) == (0, 0)
def test_flyer_crosses_pit_impassable_to_walkers() -> None:
# A pit (move_cost None, no blocks_los) at col 3: the flyer avoids the
# obstacle and reaches (0, 7); the walker cannot enter the pit cell.
assert _move_on_lane(land=30, fly=80, lane="...P....", destination=(0, 7)) == (0, 7)
assert _move_on_lane(land=30, fly=None, lane="...P....", destination=(0, 7)) == (0, 0)
def test_flyer_still_stopped_by_wall() -> None:
# A wall column (blocks_los) is a solid barrier assumed full height: it
# stops flight just like walking — no crossing and no detour.
hero = make_state(
_combatant("hero", land=30, fly=80, initiative_mod=2), "players", (0, 0),
)
foe = make_state(_combatant("foe", land=30), "monsters", (2, 7))
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", destination=(0, 7)),)
return _WAIT
spec = MapSpec(
name="wall", terrain=("...#....", "...#....", "...#...."), legend=_LEGEND,
)
eng = CombatEngine(
ScriptedRng([10, 5]), Grid.from_spec(spec), [hero, foe],
policy=policy, round_cap=1,
)
eng.run()
assert eng._states[0].pos == (0, 0)
def test_flyer_runs_over_rubble_walker_run_stops() -> None:
# Run is a straight line: an airborne runner is not hampered by the
# rubble; a walker's run stops in front of it (CRB "Difficult Terrain":
# "You can't run or charge across difficult terrain").
def _run(land: int, fly: int | None) -> tuple[int, int]:
hero = make_state(
_combatant("hero", land=land, fly=fly, initiative_mod=2), "players", (0, 0),
)
foe = make_state(_combatant("foe", land=30), "monsters", (2, 0))
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
if s.combatant.id == "hero":
return (Action(kind="run", destination=(0, 9)),)
return _WAIT
eng = CombatEngine(
ScriptedRng([10, 5]), _lane_grid("....TT...."),
[hero, foe], policy=policy, round_cap=1,
)
eng.run()
return eng._states[0].pos
assert _run(30, 80) == (0, 9)
# The walker's run destination lies beyond difficult terrain: the
# reachability verdict and the walked line agree, so the run is rejected
# outright (unreachable_destination) and the walker never starts.
assert _run(30, None) == (0, 0)
def test_flyer_5foot_step_into_rubble() -> None:
# SRD "Take 5-Foot Step": the step is legal only if movement isn't
# hampered by difficult terrain — a flyer's isn't, a walker's is.
def _step(land: int, fly: int | None) -> tuple[int, int]:
hero = make_state(
_combatant("hero", land=land, fly=fly, initiative_mod=2), "players", (0, 3),
)
foe = make_state(_combatant("foe", land=30), "monsters", (2, 0))
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
if s.combatant.id == "hero":
return (Action(kind="5foot_step", destination=(0, 4)),)
return _WAIT
eng = CombatEngine(
ScriptedRng([10, 5]), _lane_grid("...T...."),
[hero, foe], policy=policy, round_cap=1,
)
eng.run()
return eng._states[0].pos
assert _step(30, 80) == (0, 4)
assert _step(30, None) == (0, 3)
def test_movement_cost_ft_uses_the_air_view() -> None:
# Cost from the hero's cell to the target's anchor: rubble prices at 1 for
# the flyer (8 cells = 40 ft) and at 2 for the walker (13 cells = 65 ft,
# beyond its 30 ft speed so None).
grid = _lane_grid("..TTTTT..")
flyer = make_state(_combatant("flyer", land=30, fly=80), "players", (0, 0))
walker = make_state(_combatant("walker", land=30), "players", (0, 0))
target_foe = make_state(_combatant("target", land=30), "monsters", (0, 8))
eng = CombatEngine(ScriptedRng([]), grid, [flyer, target_foe])
assert eng.movement_cost_ft(flyer, target_foe) == 40
target_walker = make_state(_combatant("target", land=30), "monsters", (0, 8))
eng2 = CombatEngine(ScriptedRng([]), grid, [walker, target_walker])
assert eng2.movement_cost_ft(walker, target_walker) is None
def test_fly_equal_to_land_is_airborne() -> None:
# fly >= land: the budget comes from the fly speed, so the creature flies
# (6-cell budget, rubble priced at 1, (0, 6) reachable).
assert _move_on_lane(land=30, fly=30, lane="....TT....", destination=(0, 6)) == (0, 6)
def test_fly_slower_than_land_walks_and_pays() -> None:
# fly < land: the creature walks (budget = land) and pays the rubble cost
# like any walker — (0, 9) costs 11 cells > the 6-cell budget.
assert _move_on_lane(land=30, fly=10, lane="....TT....", destination=(0, 9)) == (0, 0)
def test_flyer_still_pays_elevation_climb() -> None:
# The elevation layer stores STEP heights, not wall heights, so the
# climbing cost still applies to flight (documented simplification): with
# a 2-cell budget (fly 10 = land 10) the step up onto the higher ledge
# costs 2 and only the flat neighbour is reachable.
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(
name="ledge", terrain=("....", "...."), legend=legend,
elevation=("..1.", "...."),
)
grid = Grid.from_spec(spec)
def _go(destination: tuple[int, int]) -> tuple[int, int]:
hero = make_state(
_combatant("hero", land=10, fly=10, initiative_mod=2), "players", (0, 0),
)
foe = make_state(_combatant("foe", land=30), "monsters", (1, 3))
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
if s.combatant.id == "hero":
return (Action(kind="move", destination=destination),)
return _WAIT
eng = CombatEngine(
ScriptedRng([10, 5]), grid, [hero, foe], policy=policy, round_cap=1,
)
eng.run()
return eng._states[0].pos
assert _go((0, 2)) == (0, 0)
assert _go((0, 1)) == (0, 1)
def test_airborne_view_preserves_combat_flags() -> None:
# The air view normalizes only the walking cost: solid barriers stay
# impassable (so LoE/cover geometry is untouched by flight), cover flags
# and the elevation layer are preserved.
legend = dict(_LEGEND)
legend["C"] = TerrainType(type="pillar", move_cost=None, cover=True)
spec = MapSpec(
name="flags", terrain=(".TP#C",), legend=legend, elevation=(".1...",),
)
grid = Grid.from_spec(spec)
view = grid.airborne_view()
wall = view.terrain_at((0, 3))
assert wall.blocks_los
assert wall.move_cost is None
pit = view.terrain_at((0, 2))
assert pit.move_cost == 1
assert not pit.blocks_los
assert view.terrain_at((0, 1)).move_cost == 1
pillar = view.terrain_at((0, 4))
assert pillar.move_cost == 1
assert pillar.cover
assert view.elevation_at((0, 1)) == grid.elevation_at((0, 1)) == 1