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
+28 -21
View File
@@ -814,27 +814,34 @@ Règles modélisées :
une politique personnalisée).
- Course (`Action(kind="run")`, action à round complet, U5e) : le combattant
se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé,
s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c), en
réutilisant exactement la mécanique de `move` (descente du champ de coût
Dijkstra, pas à pas avec provocation d'AoO — courir ne protège pas des
AoO), **sauf que la course ne traverse pas le terrain difficile**
(conformance R2 — CRB « Movement, Position and Distance — Difficult
Terrain » : « You can't run or charge across difficult terrain ») : la
descente refuse tout pas dont le footprint toucherait une case de coût > 1
(mécanisme `avoid_difficult` de `_descend_path`, symétrique du refus de la
charge dans `_charge_path_clear`) ; le combattant **avance jusqu'à
l'obstacle et s'y arrête** (comportement déterministe, retenu plutôt que
le rejet de l'action) **quand la course est auto-dirigée**. À l'achèvement, le combattant est `running` jusqu'au
début de son prochain tour : il **perd son bonus de Dextérité à la CA**
(résolu par le même chemin flat-footed/`denies_dex_to_ac` que les
conditions). Une `destination` hors budget 4× (ou invalide) est rejetée
(`unreachable_destination`) ; une `destination` joignable uniquement en
traversant le terrain difficile est **aussi rejetée** — le test de
portée (`_destination_reachable`) applique la même contrainte
`avoid_difficult` que la descente, donc le verdict et le chemin parcouru
sont cohérents (pas d'arrêt silencieux avant la destination) ; sans cible ni destination, rejet
(`missing_target`). `default_policy` ne choisit pas la course (disponible
via une politique personnalisée).
s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c).
**S4a — la course est une ligne droite** (SRD « Full-Round Actions — Run » :
« When you run, you can move up to four times your speed **in a straight
line** ») : contrairement à `move` (descente Dijkstra qui contourne les
obstacles), la course suit la **ligne de Bresenham** entre la position
courante et la cible/destination, et **s'arrête au premier obstacle** —
terrain infranchissable, terrain difficile, ou créature bloquante — en
miroir de la logique de ligne dégagée de la charge (`_run_line_path`,
`_charge_path_clear`). Le déplacement reste pas à pas avec **provocation
d'AoO** (courir ne protège pas des AoO), et conserve le budget 4×, le
drapeau `moved_this_turn` et l'exclusivité avec les autres mouvements.
Comme pour `move`, la course **traverse les cases des alliés** sans jamais
y **finir** (SRD « Movement », conformance L5 — la queue du chemin est
tronquée) ; une créature hostile (la cible incluse) arrête la course une
case avant, donc la course auto-dirigée **s'arrête adjacent à la cible**.
La course **ne traverse pas le terrain difficile** (conformance R2 — CRB
« Difficult Terrain » : « You can't run or charge across difficult
terrain ») : elle avance jusqu'à l'obstacle et s'y arrête **quand elle est
auto-dirigée**. À l'achèvement, le combattant est `running` jusqu'au début
de son prochain tour : il **perd son bonus de Dextérité à la CA** (résolu
par le même chemin flat-footed/`denies_dex_to_ac` que les conditions). Une
`destination` que la ligne droite ne peut pas honorer dans le budget 4×
(hors portée, bloquée par un obstacle/une créature, ou joignable uniquement
en tournant un coin ou en traversant le terrain difficile) est **rejetée**
(`unreachable_destination`) — le verdict et le chemin parcouru sont
cohérents (pas de contournement silencieux) ; sans cible ni destination,
rejet (`missing_target`). `default_policy` ne choisit pas la course
(disponible via une politique personnalisée).
- Pas de placement (5-foot step) : action libre qui déplace le combattant d'une
case vers l'ennemi le plus proche sans provoquer d'AoO. Exclusivité mutuelle
avec tout autre mouvement (move, charge, retraite) dans le même tour —
+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,
+99
View File
@@ -127,6 +127,24 @@ def _rubble_row_grid(size: int = 16, row: int = 5) -> Grid:
return Grid.from_spec(MapSpec(name="rubble", terrain=terrain, legend=legend))
def _walled_grid(size: int = 16) -> Grid:
"""Open grid with a wall block at rows 1-4, col 5 (a straight-line barrier).
A hero at (0, 0) running toward a foe at (2, 8) would have to turn the
corner around the wall (a Dijkstra ``move`` does); the straight-line run
must instead stop at the wall face.
"""
terrain = []
for r in range(size):
row = ["#" if (1 <= r <= 4 and c == 5) else "." for c in range(size)]
terrain.append("".join(row))
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
return Grid.from_spec(MapSpec(name="walled", terrain=tuple(terrain), legend=legend))
# ── (a) aid (attack): +2 on the ally's next attack, consumed one-shot ────────
@@ -429,6 +447,87 @@ def test_move_can_enter_difficult_terrain_but_run_cannot() -> None:
assert eng._states[0].pos[0] > 3
# ── (d3) S4a: a run is a straight line (SRD Full-Round Actions — Run) ─────────
def test_run_does_not_turn_a_corner_around_a_wall() -> None:
"""Given: hero at (0,0), a wall column at col 5 (rows 1-4), foe at (2,8)
When: hero runs toward the foe (a Dijkstra move would bend around the wall)
Then: the run follows the straight Bresenham line and stops at the wall
face — it does NOT turn the corner (SRD Run: "move up to four times your
speed in a straight line")."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", target_id="foe"),)
return _WAIT
grid = _walled_grid(size=12)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (2, 8))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
eng.run()
hero_state = eng._states[0]
# The straight line (0,0)->(2,8) crosses col 5 at rows 1-2 (both wall), so
# the run halts before the wall face and never reaches or bends past it.
assert hero_state.pos[1] < 5
assert hero_state.running is True
# Contrast: a plain move DOES route around the wall (bends the corner).
hero2 = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe2 = make_state(_combatant("foe"), "monsters", (2, 8))
eng2 = _duo(
lambda _e, s: (
(Action(kind="move", target_id="foe"),)
if s.combatant.id == "hero" else _WAIT
),
[10, 5], round_cap=1, hero=hero2, foe=foe2, grid=grid,
)
eng2.run()
assert eng2._states[0].pos[1] > 5 # the move got around to the far side
def test_clear_straight_run_still_works() -> None:
"""Given: an open grid, hero (0,0), foe far along a clear straight line
When: hero runs toward the foe
Then: the straight-line run reaches adjacent to the target (unchanged
behaviour on an unobstructed line)."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", target_id="foe"),)
return _WAIT
grid = _open_grid(16)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (0, 15))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
eng.run()
hero_state = eng._states[0]
# 4x speed = 24 cells; the 15-cell straight run reaches (0,14), adjacent.
assert hero_state.pos == (0, 14)
assert hero_state.running is True
def test_run_destination_around_a_corner_is_rejected() -> None:
"""Given: the wall column, hero (0,0)
When: hero runs to an explicit destination only reachable by turning the
corner (2,8)
Then: the straight line cannot honour it, so the destination is rejected
(unreachable_destination) rather than the run bending around the wall."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(2, 8)),)
return _WAIT
grid = _walled_grid(size=12)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, grid=grid)
result = eng.run()
assert eng._states[0].pos == (0, 0)
assert "round 1 hero: run rejected (unreachable_destination)" in result.transcript
# ── (e) a running combatant loses its Dex bonus to AC ────────────────────────
def test_running_combatant_is_denied_dex_to_ac() -> None:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: