fix(rules): stop a run from crossing difficult terrain

PF1e CRB 'Movement, Position and Distance - Difficult Terrain': 'You
can't run or charge across difficult terrain.' The run action reused the
move machinery (_descend_path), so it crossed rubble at 2x cost within
the 4x budget. The descent now takes an avoid_difficult option that
refuses every step whose footprint would touch a cell with move cost > 1
(the same rejection charge applies in _charge_path_clear); the run
moves up to the obstacle and stops there (deterministic, documented).
Move and charge behavior are unchanged.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-11 22:16:54 +02:00
parent 4d89305881
commit b453b19be2
3 changed files with 128 additions and 14 deletions
+42 -8
View File
@@ -3473,10 +3473,16 @@ class CombatEngine:
Reuses the move machinery: the Dijkstra descent toward an explicit
destination (U5c) or the resolved target, walked step-by-step with
the usual AoO provocation, under a 4x speed budget. On completion the
combatant is ``running`` until the start of its next turn, which
denies it its Dexterity bonus to AC. An explicit destination that
cannot be honoured within the 4x budget is rejected with
the usual AoO provocation, under a 4x speed budget. PF1e forbids
running across difficult terrain (CRB "Movement, Position and
Distance — Difficult Terrain": "You can't run or charge across
difficult terrain"), so the descent refuses every step into a
difficult cell: the run moves up to the obstacle and stops there
(deterministic; the same stop-short tolerance the greedy descent
already applies to any other blockage). On completion the combatant
is ``running`` until the start of its next turn, which denies it its
Dexterity bonus to AC. An explicit destination that cannot be
honoured within the 4x budget is rejected with
``unreachable_destination``; with neither target nor destination the
U1 gate rejects the action with ``missing_target``.
"""
@@ -3503,13 +3509,16 @@ class CombatEngine:
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
)
path = self._descend_path(
state, action.destination, speed_factor=_RUN_SPEED_FACTOR
state,
action.destination,
speed_factor=_RUN_SPEED_FACTOR,
avoid_difficult=True,
)
self._walk_steps(state, path)
state.moved_this_turn = True
elif target is not None:
path = self._descend_path(
state, target.pos, speed_factor=_RUN_SPEED_FACTOR
state, target.pos, speed_factor=_RUN_SPEED_FACTOR, avoid_difficult=True
)
if self._walk_steps(state, path):
state.moved_this_turn = True
@@ -5720,11 +5729,19 @@ class CombatEngine:
return self._descend_path(state, target.pos)
def _descend_path(
self, state: CombatantState, anchor: Pos, *, speed_factor: int = 1
self,
state: CombatantState,
anchor: Pos,
*,
speed_factor: int = 1,
avoid_difficult: bool = False,
) -> list[Pos]:
"""Greedy walk of the anchor over the cost field from ``anchor``.
``speed_factor`` widens the movement budget (run: 4x speed, U5e).
``avoid_difficult`` excludes difficult-terrain cells from the walk
(PF1e: a run cannot cross difficult terrain), so the walk stops in
front of the obstacle.
"""
speed_mult = self._get_speed_mult(state)
speed_cells = int(
@@ -5743,7 +5760,9 @@ class CombatEngine:
current = state.pos
budget = speed_cells
while budget > 0:
best = self._best_move_step(state, current, to_target, budget)
best = self._best_move_step(
state, current, to_target, budget, avoid_difficult=avoid_difficult
)
if best is None:
break
_, step_cost, nxt = best
@@ -5758,6 +5777,8 @@ class CombatEngine:
current: Pos,
to_target: dict[Pos, int],
budget: int,
*,
avoid_difficult: bool = False,
) -> tuple[int, int, Pos] | None:
"""Pick the adjacent anchor that best descends the cost field toward the target."""
current_cost = self._footprint_cost(self._footprint_at(current, state), to_target)
@@ -5770,6 +5791,8 @@ class CombatEngine:
footprint = self._footprint_at(nxt, state)
if not self._cells_free(footprint, state):
continue
if avoid_difficult and self._footprint_touches_difficult(footprint):
continue
nxt_cost = self._footprint_cost(footprint, to_target)
if nxt_cost is None or nxt_cost >= current_cost:
continue
@@ -5781,6 +5804,17 @@ class CombatEngine:
best = (score, step, nxt)
return best
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 _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):
return False
terrain = self._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]:
"""Movement path away from ``threat``, up to double speed.