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