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). une politique personnalisée).
- Course (`Action(kind="run")`, action à round complet, U5e) : le combattant - Course (`Action(kind="run")`, action à round complet, U5e) : le combattant
se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé, 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 s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c).
réutilisant exactement la mécanique de `move` (descente du champ de coût **S4a — la course est une ligne droite** (SRD « Full-Round Actions — Run » :
Dijkstra, pas à pas avec provocation d'AoO — courir ne protège pas des « When you run, you can move up to four times your speed **in a straight
AoO), **sauf que la course ne traverse pas le terrain difficile** line** ») : contrairement à `move` (descente Dijkstra qui contourne les
(conformance R2 — CRB « Movement, Position and Distance — Difficult obstacles), la course suit la **ligne de Bresenham** entre la position
Terrain » : « You can't run or charge across difficult terrain ») : la courante et la cible/destination, et **s'arrête au premier obstacle** —
descente refuse tout pas dont le footprint toucherait une case de coût > 1 terrain infranchissable, terrain difficile, ou créature bloquante — en
(mécanisme `avoid_difficult` de `_descend_path`, symétrique du refus de la miroir de la logique de ligne dégagée de la charge (`_run_line_path`,
charge dans `_charge_path_clear`) ; le combattant **avance jusqu'à `_charge_path_clear`). Le déplacement reste pas à pas avec **provocation
l'obstacle et s'y arrête** (comportement déterministe, retenu plutôt que d'AoO** (courir ne protège pas des AoO), et conserve le budget 4×, le
le rejet de l'action) **quand la course est auto-dirigée**. À l'achèvement, le combattant est `running` jusqu'au drapeau `moved_this_turn` et l'exclusivité avec les autres mouvements.
début de son prochain tour : il **perd son bonus de Dextérité à la CA** Comme pour `move`, la course **traverse les cases des alliés** sans jamais
(résolu par le même chemin flat-footed/`denies_dex_to_ac` que les y **finir** (SRD « Movement », conformance L5 — la queue du chemin est
conditions). Une `destination` hors budget 4× (ou invalide) est rejetée tronquée) ; une créature hostile (la cible incluse) arrête la course une
(`unreachable_destination`) ; une `destination` joignable uniquement en case avant, donc la course auto-dirigée **s'arrête adjacent à la cible**.
traversant le terrain difficile est **aussi rejetée** — le test de La course **ne traverse pas le terrain difficile** (conformance R2 — CRB
portée (`_destination_reachable`) applique la même contrainte « Difficult Terrain » : « You can't run or charge across difficult
`avoid_difficult` que la descente, donc le verdict et le chemin parcouru terrain ») : elle avance jusqu'à l'obstacle et s'y arrête **quand elle est
sont cohérents (pas d'arrêt silencieux avant la destination) ; sans cible ni destination, rejet auto-dirigée**. À l'achèvement, le combattant est `running` jusqu'au début
(`missing_target`). `default_policy` ne choisit pas la course (disponible de son prochain tour : il **perd son bonus de Dextérité à la CA** (résolu
via une politique personnalisée). 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 - 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 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 — avec tout autre mouvement (move, charge, retraite) dans le même tour —
+67 -29
View File
@@ -3982,19 +3982,25 @@ class CombatEngine:
) -> ActionResult: ) -> ActionResult:
"""Execute the run action (PF1e, U5e): full-round movement at 4x speed. """Execute the run action (PF1e, U5e): full-round movement at 4x speed.
Reuses the move machinery: the Dijkstra descent toward an explicit PF1e (SRD "Full-Round Actions — Run"): "When you run, you can move up
destination (U5c) or the resolved target, walked step-by-step with to four times your speed in a straight line" — a run is NOT a ``move``:
the usual AoO provocation, under a 4x speed budget. PF1e forbids it cannot turn a corner. The path is the straight Bresenham line from
running across difficult terrain (CRB "Movement, Position and the combatant's position toward an explicit destination (U5c) or the
Distance — Difficult Terrain": "You can't run or charge across resolved target, walked under a 4x speed budget with the usual step-by-
difficult terrain"), so the descent refuses every step into a step AoO provocation, and truncated at the first obstacle (impassable
difficult cell: the run moves up to the obstacle and stops there terrain, a difficult-terrain cell, or a blocking creature) exactly like
(deterministic; the same stop-short tolerance the greedy descent the charge's clear-path rule (``_run_line_path``). PF1e forbids running
already applies to any other blockage). On completion the combatant across difficult terrain (CRB "Movement, Position and Distance —
is ``running`` until the start of its next turn, which denies it its Difficult Terrain": "You can't run or charge across difficult
Dexterity bonus to AC. An explicit destination that cannot be terrain"), so a difficult cell stops the run short. An auto-directed
honoured within the 4x budget WITHOUT crossing difficult terrain (the run advances until the obstacle and stops there (deterministic); a
same constraint the descent applies) is rejected with 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 ``unreachable_destination``; with neither target nor destination the
U1 gate rejects the action with ``missing_target``. 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)") self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK) return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if action.destination is not None: if action.destination is not None:
reachable = self._destination_reachable( if action.destination == state.pos:
state, action.destination, speed_factor=_RUN_SPEED_FACTOR, path: list[Pos] = [] # already there: a valid zero-length run
friendly_passable=True, avoid_difficult=True, else:
) path = self._run_line_path(state, action.destination)
if not reachable: 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( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})" f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
@@ -4021,20 +4029,10 @@ class CombatEngine:
return ActionResult( return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION 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) self._walk_steps(state, path)
state.moved_this_turn = True state.moved_this_turn = True
elif target is not None: elif target is not None:
path = self._descend_path( path = self._run_line_path(state, target.pos)
state, target.pos, speed_factor=_RUN_SPEED_FACTOR, avoid_difficult=True,
friendly_passable=True,
)
if self._walk_steps(state, path): if self._walk_steps(state, path):
state.moved_this_turn = True state.moved_this_turn = True
state.running = True state.running = True
@@ -6943,6 +6941,46 @@ class CombatEngine:
path.pop() path.pop()
return path 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( def _best_move_step(
self, self,
state: CombatantState, 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)) 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 ──────── # ── (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 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 ──────────────────────── # ── (e) a running combatant loses its Dex bonus to AC ────────────────────────
def test_running_combatant_is_denied_dex_to_ac() -> None: def test_running_combatant_is_denied_dex_to_ac() -> None:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: