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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user