fix(rules): make the run reachability check avoid difficult terrain

PF1e (CRB Movement): "You can't run or charge across difficult
terrain". _execute_run checked the explicit destination against the full
cost field (which routes through difficult terrain at 2x) but descended
with avoid_difficult=True, so a destination beyond rubble was accepted
(ok=True) yet silently truncated at the obstacle. _destination_reachable
now takes the same avoid_difficult constraint (difficult cells removed
from the field; a destination whose footprint touches difficult terrain is
not a legal run endpoint either), so verdict and path agree. The test that
pinned the old silent-stop-short convention now expects
unreachable_destination; README updated.

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-12 11:53:27 +02:00
parent 8043dd393c
commit e3818fa6c9
3 changed files with 57 additions and 13 deletions
+23 -3
View File
@@ -3680,7 +3680,8 @@ class CombatEngine:
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
honoured within the 4x budget WITHOUT crossing difficult terrain (the
same constraint the descent applies) is rejected with
``unreachable_destination``; with neither target nor destination the
U1 gate rejects the action with ``missing_target``.
"""
@@ -3697,7 +3698,7 @@ class CombatEngine:
if action.destination is not None:
reachable = self._destination_reachable(
state, action.destination, speed_factor=_RUN_SPEED_FACTOR,
friendly_passable=True,
friendly_passable=True, avoid_difficult=True,
)
if not reachable:
self._log(
@@ -6091,13 +6092,17 @@ class CombatEngine:
*,
speed_factor: int = 1,
friendly_passable: bool = False,
avoid_difficult: bool = False,
) -> bool:
"""True when destination is passable, unoccupied and within this speed.
``speed_factor`` widens the budget (run: 4x speed, U5e).
``friendly_passable`` routes the cost field around allies' squares
(move/run may cross them, SRD Movement); the destination itself must
still be completely free.
still be completely free. ``avoid_difficult`` removes difficult cells
from the cost field, mirroring the descent constraint of a run
(CRB: "You can't run or charge across difficult terrain") so the
reachability verdict and the walked path agree.
"""
speed_cells = int(
_speed_ft(state)
@@ -6109,10 +6114,25 @@ class CombatEngine:
return destination == state.pos
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)
):
return False
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
if avoid_difficult:
blocked = blocked | self._difficult_cells()
field = self._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."""
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))
)
def _walk_steps(
self, state: CombatantState, path: list[Pos]
) -> list[Pos]: