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 - 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 the true shortest path (Dijkstra cost field from the target, 5-10-5
diagonals), stopping adjacent to the nearest enemy; ties keep delta order. 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 The speed budget is the best of land and fly speed (V4). A creature whose
obstacles or difficult terrain is NOT modeled (grid costs still apply). 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 - Line of effect gates all attacks: a target fully behind blocking terrain
cannot be attacked, and the policy moves to gain sight instead. 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 - 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: def _speed_ft(state: CombatantState) -> int:
"""Movement budget in feet: the best of the combatant's land and fly speed. """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; 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 when its fly speed is at least its land speed (see ``_is_airborne``). The
passability and terrain costs still apply to the whole budget. No budget itself is land-or-fly; an airborne creature additionally moves over
fly maneuverability, no 3D flight. Swim/climb/burrow speeds do not exist ground terrain at cost 1 (``Grid.airborne_view``). No fly maneuverability,
in the model (fly only). 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) 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]: def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end).""" """Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
r0, c0 = start r0, c0 = start
@@ -1598,6 +1623,7 @@ class CombatEngine:
raise ValueError(msg) raise ValueError(msg)
self._rng = rng self._rng = rng
self._grid = grid self._grid = grid
self._airborne_grid = grid.airborne_view()
self._states = states self._states = states
self._round_cap = round_cap self._round_cap = round_cap
if llm_policy is not None: if llm_policy is not None:
@@ -3022,11 +3048,25 @@ class CombatEngine:
"""Height of the creature's anchor cell (0 when no elevation layer).""" """Height of the creature's anchor cell (0 when no elevation layer)."""
return self._grid.elevation_at(state.pos) 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: 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)
grid = self._movement_grid(mover)
return all( 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: 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). though the movement may not END there (``_cells_free`` gates that).
""" """
hostile = self._blocked_hostile(mover) hostile = self._blocked_hostile(mover)
grid = self._movement_grid(mover)
return all( 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]: 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 (API publique, lecture pour politique LLM.) Same cost model as
``Grid.reachable``: 5-10-5 diagonals, terrain, elevation, blocked ``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) blocked = self._blocked(state)
grid = self._movement_grid(state)
if budget is None: if budget is None:
field = self._grid.reachable(state.pos, None, blocked) field = grid.reachable(state.pos, None, blocked)
else: else:
budget_cells = budget // _SQUARE_FT budget_cells = budget // _SQUARE_FT
if budget_cells < 1: if budget_cells < 1:
return {} 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()} return {pos: cost * _SQUARE_FT for pos, cost in field.items()}
def movement_cost_ft(self, state: CombatantState, target: CombatantState) -> int | None: 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 ``_move_path``: a full Dijkstra field from the target's anchor (other
active states' cells blocked), then the cheapest field value over active states' cells blocked), then the cheapest field value over
``state``'s footprint, compared against the combatant's effective ``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) 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) cost_cells = self._footprint_cost(state.occupied_cells(), field)
if cost_cells is None: if cost_cells is None:
return None return None
@@ -4840,12 +4884,12 @@ class CombatEngine:
return ChargeResult(weapon=weapon, distance_ft=distance_ft, reason=None) return ChargeResult(weapon=weapon, distance_ft=distance_ft, reason=None)
return ChargeResult(weapon=None, distance_ft=0, reason="no_charge_path") return ChargeResult(weapon=None, distance_ft=0, reason="no_charge_path")
def _path_cost_5105(self, path: list[Pos]) -> int: def _path_cost_5105(self, path: list[Pos], grid: Grid) -> int:
"""Total 5-10-5 movement cost of a path (diagonal parity threaded).""" """Total 5-10-5 movement cost of a path on ``grid`` (parity threaded)."""
total = 0 total = 0
diagonals_used = 0 diagonals_used = 0
for frm, to in pairwise(path): 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]: if frm[0] != to[0] and frm[1] != to[1]:
diagonals_used += 1 diagonals_used += 1
return total return total
@@ -4869,8 +4913,11 @@ class CombatEngine:
Ties break toward lower distance from state. Ties break toward lower distance from state.
""" """
target_cells = target.occupied_cells() target_cells = target.occupied_cells()
grid = self._movement_grid(state)
candidates: list[tuple[int, Pos]] = [] 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) footprint = self._footprint_at(end, state)
if not self._cells_free(footprint, state): if not self._cells_free(footprint, state):
continue continue
@@ -4888,7 +4935,7 @@ class CombatEngine:
continue continue
# The charge budget is a movement budget: count it in 5-10-5 # The charge budget is a movement budget: count it in 5-10-5
# squares (diagonal parity threaded), not raw cells. # 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 continue
candidates.append((len(path), end)) candidates.append((len(path), end))
if not candidates: if not candidates:
@@ -4902,8 +4949,13 @@ class CombatEngine:
reach_cells: int, reach_cells: int,
blocked: frozenset[Pos], blocked: frozenset[Pos],
start: Pos, start: Pos,
grid: Grid,
) -> list[Pos]: ) -> 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() result: set[Pos] = set()
for tr, tc in target.occupied_cells(): for tr, tc in target.occupied_cells():
for dr in range(-reach_cells, reach_cells + 1): for dr in range(-reach_cells, reach_cells + 1):
@@ -4915,7 +4967,7 @@ class CombatEngine:
end = (tr + dr, tc + dc) end = (tr + dr, tc + dc)
if end == start: if end == start:
continue 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 continue
if end in blocked: if end in blocked:
continue continue
@@ -4939,19 +4991,24 @@ class CombatEngine:
below. Difficult terrain is tested over the WHOLE footprint, not below. Difficult terrain is tested over the WHOLE footprint, not
just the anchor cell (SRD "Movement, Position and Distance": "You just the anchor cell (SRD "Movement, Position and Distance": "You
can't run or charge across difficult terrain" - a 2x2 creature with 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 prev = state.pos
for pos in path: for pos in path:
footprint = self._footprint_at(pos, state) footprint = self._footprint_at(pos, state)
if not self._cells_free(footprint, state): if not self._cells_free(footprint, state):
return False return False
if self._footprint_touches_difficult(footprint): if self._footprint_touches_difficult(state, footprint):
return False return False
if ( if (
prev[0] != pos[0] prev[0] != pos[0]
and prev[1] != pos[1] and prev[1] != pos[1]
and not self._grid.diagonal_allowed(prev, pos) and not grid.diagonal_allowed(prev, pos)
): ):
return False return False
prev = pos prev = pos
@@ -9349,12 +9406,15 @@ class CombatEngine:
# SRD "Take 5-Foot Step": "You can only take a 5-foot-step if your # 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 # 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).
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)") self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)")
return return
path = self._move_path(state, target) path = self._move_path(state, target)
start = state.pos 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)") self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)")
return return
state.pos = path[0] state.pos = path[0]
@@ -9376,16 +9436,19 @@ class CombatEngine:
return ActionResult(kind=kind, ok=True, reason=_REASON_OK) return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
adjacent = self._grid.distance(state.pos, destination) == 1 adjacent = self._grid.distance(state.pos, destination) == 1
free = self._cells_free(self._footprint_at(destination, state), state) 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 # PF1e (SRD Measuring Distance): "You can't move diagonally past a
# corner (even by taking a 5-foot step)." # corner (even by taking a 5-foot step)."
diagonal = state.pos[0] != destination[0] and state.pos[1] != destination[1] 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 # 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 # 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 ( step_clear = not (
self._footprint_touches_difficult(self._footprint_at(state.pos, state)) self._footprint_touches_difficult(state, self._footprint_at(state.pos, state))
or self._footprint_touches_difficult(self._footprint_at(destination, 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: if not adjacent or not free or not corner_clear or not step_clear:
self._log( self._log(
@@ -9497,22 +9560,23 @@ class CombatEngine:
if not self._cells_free(self._footprint_at(destination, state), state): if not self._cells_free(self._footprint_at(destination, state), state):
return False return False
if avoid_difficult and self._footprint_touches_difficult( if avoid_difficult and self._footprint_touches_difficult(
self._footprint_at(destination, state) state, self._footprint_at(destination, state)
): ):
return False return False
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state) blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
grid = self._movement_grid(state)
if avoid_difficult: if avoid_difficult:
blocked = blocked | self._difficult_cells() blocked = blocked | self._difficult_cells(grid)
field = self._grid.reachable(state.pos, speed_cells, blocked) field = grid.reachable(state.pos, speed_cells, blocked)
return destination in field return destination in field
def _difficult_cells(self) -> frozenset[Pos]: def _difficult_cells(self, grid: Grid) -> frozenset[Pos]:
"""Every passable cell on the grid whose terrain costs more than one.""" """Every passable cell of ``grid`` whose terrain costs more than one."""
return frozenset( return frozenset(
(row, col) (row, col)
for row in range(self._grid.height) for row in range(grid.height)
for col in range(self._grid.width) for col in range(grid.width)
if self._is_difficult_cell((row, col)) if self._is_difficult_cell(grid, (row, col))
) )
def _walk_steps( def _walk_steps(
@@ -9595,7 +9659,7 @@ class CombatEngine:
if speed_cells <= 0: if speed_cells <= 0:
return [] return []
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state) 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: if self._footprint_cost(state.occupied_cells(), to_target) is None:
return [] return []
path: list[Pos] = [] path: list[Pos] = []
@@ -9642,7 +9706,11 @@ class CombatEngine:
Through a Square"), which is crossed with an entry AoO and never Through a Square"), which is crossed with an entry AoO and never
ended on. Friendly squares ended on. Friendly squares
are still crossed (SRD Movement, conformance L5) but never ended on — 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) speed_mult = self._get_speed_mult(state)
budget = int( budget = int(
@@ -9650,6 +9718,7 @@ class CombatEngine:
) )
if budget <= 0: if budget <= 0:
return [] return []
grid = self._movement_grid(state)
path: list[Pos] = [] path: list[Pos] = []
current = state.pos current = state.pos
diagonals_used = 0 diagonals_used = 0
@@ -9661,15 +9730,15 @@ class CombatEngine:
if ( if (
current[0] != nxt[0] current[0] != nxt[0]
and current[1] != nxt[1] and current[1] != nxt[1]
and not self._grid.diagonal_allowed(current, nxt) and not grid.diagonal_allowed(current, nxt)
): ):
break break
footprint = self._footprint_at(nxt, state) footprint = self._footprint_at(nxt, state)
if self._footprint_touches_difficult(footprint): if self._footprint_touches_difficult(state, footprint):
break break
if not self._cells_walkable(footprint, state): if not self._cells_walkable(footprint, state):
break break
step = self._grid.step_cost(current, nxt, diagonals_used) step = grid.step_cost(current, nxt, diagonals_used)
if step > budget: if step > budget:
break break
path.append(nxt) path.append(nxt)
@@ -9698,6 +9767,7 @@ class CombatEngine:
return None return None
best: tuple[int, int, Pos] | None = None best: tuple[int, int, Pos] | None = None
row, col = current row, col = current
grid = self._movement_grid(state)
fits = self._cells_walkable if friendly_passable else self._cells_free fits = self._cells_walkable if friendly_passable else self._cells_free
for d_row, d_col in _STEP_DELTAS: for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col) 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 # a corner (even by taking a 5-foot step)" — same corner rule the
# explicit step (``_step5_to``) and flee (``_best_flee_step``) # explicit step (``_step5_to``) and flee (``_best_flee_step``)
# paths already enforce. # paths already enforce.
if not self._grid.diagonal_allowed(current, nxt): if not grid.diagonal_allowed(current, nxt):
continue continue
footprint = self._footprint_at(nxt, state) footprint = self._footprint_at(nxt, state)
if not fits(footprint, state): if not fits(footprint, state):
continue continue
if avoid_difficult and self._footprint_touches_difficult(footprint): if avoid_difficult and self._footprint_touches_difficult(state, footprint):
continue continue
nxt_cost = self._footprint_cost(footprint, to_target) nxt_cost = self._footprint_cost(footprint, to_target)
if nxt_cost is None or nxt_cost >= current_cost: if nxt_cost is None or nxt_cost >= current_cost:
@@ -9766,9 +9836,17 @@ class CombatEngine:
frontier.append((nxt, cost)) frontier.append((nxt, cost))
return False return False
def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool: def _footprint_touches_difficult(
"""True if any footprint cell is difficult terrain (move cost > 1).""" self, state: CombatantState, footprint: frozenset[Pos]
return any(self._is_difficult_cell(cell) for cell in footprint) ) -> 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( def _footprint_step_cost(
self, state: CombatantState, frm: Pos, to: Pos, diagonals_used: int 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 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 1x1 creature the footprint is the anchor cell, so this is exactly
``grid.step_cost`` - bit-identical to the old anchor-only pricing. ``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) footprint = self._footprint_at(to, state)
hardest = 1 hardest = 1
for cell in footprint: for cell in footprint:
if not self._grid.in_bounds(cell): if not grid.in_bounds(cell):
continue continue
cost = self._grid.terrain_at(cell).move_cost cost = grid.terrain_at(cell).move_cost
if cost is not None and cost > hardest: if cost is not None and cost > hardest:
hardest = cost 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: def _is_difficult_cell(self, grid: Grid, pos: Pos) -> bool:
"""True for a passable cell whose terrain costs more than one square.""" """True for a passable cell of ``grid`` whose terrain costs more than one."""
if not self._grid.in_bounds(pos): if not grid.in_bounds(pos):
return False 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 return terrain.move_cost is not None and terrain.move_cost > 1
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]: def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
@@ -9819,7 +9901,7 @@ class CombatEngine:
if max_cells <= 0: if max_cells <= 0:
return [] return []
blocked = self._blocked(state) 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: if self._footprint_cost(state.occupied_cells(), from_threat) is None:
return [] return []
path: list[Pos] = [] path: list[Pos] = []
@@ -9854,12 +9936,13 @@ class CombatEngine:
return None return None
best: tuple[int, int, Pos] | None = None best: tuple[int, int, Pos] | None = None
row, col = current row, col = current
grid = self._movement_grid(state)
for d_row, d_col in _STEP_DELTAS: for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col) nxt = (row + d_row, col + d_col)
footprint = self._footprint_at(nxt, state) footprint = self._footprint_at(nxt, state)
if not self._cells_free(footprint, state): if not self._cells_free(footprint, state):
continue continue
if not self._grid.diagonal_allowed(current, nxt): if not grid.diagonal_allowed(current, nxt):
continue continue
nxt_cost = self._footprint_cost(footprint, from_threat) nxt_cost = self._footprint_cost(footprint, from_threat)
if nxt_cost is None or nxt_cost <= current_cost: 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 from pf1e_simulator.map import MapSpec, Pos, TerrainType
_DIFFICULT_TERRAIN_COST = 2 # rubble, water, etc. count as 2 squares (PF1e) _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, ...] = ( _DIRECTIONS: tuple[Pos, ...] = (
(-1, -1), (-1, -1),
@@ -48,6 +49,41 @@ class Grid:
self._width = len(cells[0]) if cells else 0 self._width = len(cells[0]) if cells else 0
self._elevation = elevation 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 @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)
+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, 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 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 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: fly speed counts as 0, so a land-only creature is unchanged). A creature
a creature that has a fly speed is assumed to use it, but flying OVER whose budget comes from its fly speed (fly >= land) is AIRBORNE
obstacles or difficult terrain is NOT modeled — the grid's passability and (``_is_airborne``): it moves on ``Grid.airborne_view``, where ground difficult
terrain costs still apply to the whole budget. The observation snapshot adds terrain costs 1 and cells impassable to walkers (pits, water, rubble) are
an additive ``speed_effective_ft`` key; ``speed_land_ft``/``speed_fly_ft`` are crossable — SRD "Movement, Position, and Distance": "Flying and incorporeal
kept as-is. 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 from __future__ import annotations
@@ -77,6 +81,45 @@ def _corridor(cols: int) -> Grid:
return Grid.from_spec(spec) 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( def _run_move(
*, land: int, fly: int | None, destination: tuple[int, int], cols: int, *, land: int, fly: int | None, destination: tuple[int, int], cols: int,
) -> CombatantState: ) -> CombatantState:
@@ -220,3 +263,236 @@ def test_fly_lower_than_land_uses_land() -> None:
assert mover.pos == (0, 6) assert mover.pos == (0, 6)
mover = _run_move(land=30, fly=10, destination=(0, 7), cols=12) mover = _run_move(land=30, fly=10, destination=(0, 7), cols=12)
assert mover.pos == (0, 0) 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