fix(rules): constrain a run to a straight line

SRD Full-Round Actions - Run: 'you can move up to four times your speed in a straight line'. _execute_run reused the Dijkstra move machinery, so it turned corners around walls. It now walks the straight Bresenham line (_run_line_path) and stops at the first obstacle (impassable terrain, difficult terrain, or a blocking creature), mirroring the charge clear-path logic. Keeps the 4x budget, step-by-step AoO provocation, moved_this_turn and the running/Dex-loss flag; a destination the straight line cannot honour is rejected (unreachable_destination).

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-13 14:48:25 +02:00
parent f5dc9da0c4
commit 6702efc652
3 changed files with 194 additions and 50 deletions
+67 -29
View File
@@ -3982,19 +3982,25 @@ class CombatEngine:
) -> ActionResult:
"""Execute the run action (PF1e, U5e): full-round movement at 4x speed.
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. 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 WITHOUT crossing difficult terrain (the
same constraint the descent applies) is rejected with
PF1e (SRD "Full-Round Actions — Run"): "When you run, you can move up
to four times your speed in a straight line" — a run is NOT a ``move``:
it cannot turn a corner. The path is the straight Bresenham line from
the combatant's position toward an explicit destination (U5c) or the
resolved target, walked under a 4x speed budget with the usual step-by-
step AoO provocation, and truncated at the first obstacle (impassable
terrain, a difficult-terrain cell, or a blocking creature) exactly like
the charge's clear-path rule (``_run_line_path``). PF1e forbids running
across difficult terrain (CRB "Movement, Position and Distance —
Difficult Terrain": "You can't run or charge across difficult
terrain"), so a difficult cell stops the run short. An auto-directed
run advances until the obstacle and stops there (deterministic); a
hostile creature (the target included) halts it one cell short, so it
ends adjacent to the target. Friendly squares are still crossed, never
ended on (SRD Movement, conformance L5). 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 the straight line
cannot honour within the 4x budget (blocked by terrain, difficult
terrain, a creature, or out of range) is rejected with
``unreachable_destination``; with neither target nor destination the
U1 gate rejects the action with ``missing_target``.
"""
@@ -4009,11 +4015,13 @@ class CombatEngine:
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if action.destination is not None:
reachable = self._destination_reachable(
state, action.destination, speed_factor=_RUN_SPEED_FACTOR,
friendly_passable=True, avoid_difficult=True,
)
if not reachable:
if action.destination == state.pos:
path: list[Pos] = [] # already there: a valid zero-length run
else:
path = self._run_line_path(state, action.destination)
if not path or path[-1] != action.destination:
path = [] # the straight line could not honour it
if not path and action.destination != state.pos:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
@@ -4021,20 +4029,10 @@ class CombatEngine:
return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
)
path = self._descend_path(
state,
action.destination,
speed_factor=_RUN_SPEED_FACTOR,
avoid_difficult=True,
friendly_passable=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, avoid_difficult=True,
friendly_passable=True,
)
path = self._run_line_path(state, target.pos)
if self._walk_steps(state, path):
state.moved_this_turn = True
state.running = True
@@ -6943,6 +6941,46 @@ class CombatEngine:
path.pop()
return path
def _run_line_path(self, state: CombatantState, goal: Pos) -> list[Pos]:
"""Straight-line run path toward ``goal``, truncated at the first obstacle.
PF1e (SRD "Full-Round Actions — Run"): "When you run, you can move up
to four times your speed in a straight line" — a run CANNOT turn a
corner the way a Dijkstra ``move`` does. The path is the Bresenham
line from the combatant's anchor toward ``goal``, walked under the 4x
speed budget and stopped at the first obstacle, mirroring the charge's
straight-line clear-path logic (CRB "Charge"): impassable terrain, a
difficult-terrain cell (CRB "Difficult Terrain": "You can't run or
charge across difficult terrain"), or a blocking creature. A hostile
creature (including the target itself) halts the walk one cell short,
so an auto-directed run ends adjacent to the target. Friendly squares
are still crossed (SRD Movement, conformance L5) but never ended on —
the tail is trimmed like ``_descend_path``.
"""
speed_mult = self._get_speed_mult(state)
budget = int(
_speed_ft(state) // _SQUARE_FT * speed_mult * _RUN_SPEED_FACTOR
)
if budget <= 0:
return []
path: list[Pos] = []
current = state.pos
for nxt in _bresenham_line(state.pos, goal):
footprint = self._footprint_at(nxt, state)
if self._footprint_touches_difficult(footprint):
break
if not self._cells_walkable(footprint, state):
break
step = self._grid.step_cost(current, nxt, 0)
if step > budget:
break
path.append(nxt)
current = nxt
budget -= step
while path and not self._cells_free(self._footprint_at(path[-1], state), state):
path.pop()
return path
def _best_move_step(
self,
state: CombatantState,