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:
2026-09-11 22:16:54 +02:00
parent 4d89305881
commit b453b19be2
3 changed files with 128 additions and 14 deletions
+11 -4
View File
@@ -693,10 +693,17 @@ Règles modélisées :
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). À 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
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). À 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`) ; sans cible ni destination, rejet
(`missing_target`). `default_policy` ne choisit pas la course (disponible
via une politique personnalisée).
+42 -8
View File
@@ -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.
+75 -2
View File
@@ -117,6 +117,16 @@ def _open_grid(size: int = 16) -> Grid:
return Grid.from_spec(spec)
def _rubble_row_grid(size: int = 16, row: int = 5) -> Grid:
"""Open grid whose single ``row`` is rubble (difficult terrain, cost 2)."""
terrain = tuple("." * size if r != row else "~" * size for r in range(size))
legend = {
".": TerrainType(type="floor", move_cost=1),
"~": TerrainType(type="rubble", move_cost=2),
}
return Grid.from_spec(MapSpec(name="rubble", terrain=terrain, legend=legend))
# ── (a) aid (attack): +2 on the ally's next attack, consumed one-shot ────────
@@ -334,9 +344,72 @@ def test_run_toward_target_outreaches_normal_move() -> None:
assert grid.distance(run_hero.pos, foe.pos) < grid.distance(move_hero.pos, foe2.pos)
# ── (d2) R2: a run cannot cross difficult terrain ────────────────────────────
def test_run_stops_before_difficult_terrain_toward_target() -> None:
"""Given: hero at (0,0), a rubble row across row 3, foe beyond it at (6,0)
When: hero runs (4x speed = 24 cells, far beyond the 3-cell obstacle)
Then: hero stops in front of the rubble and never enters it (CRB: "You
can't run or charge across difficult terrain")."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", target_id="foe"),)
return _WAIT
grid = _rubble_row_grid(size=8, row=3)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (6, 0))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
eng.run()
# The footprint never touches the rubble row, and the run stopped at it.
assert all(cell[0] != 3 for cell in eng._states[0].occupied_cells())
assert eng._states[0].pos == (2, 0)
def test_run_stops_before_difficult_terrain_to_destination() -> None:
"""Given: the same rubble row
When: hero runs to an explicit destination beyond it
Then: the descent stops in front of the rubble (the destination is
reachable in budget, but the run cannot cross — it moves up to the
obstacle and stops)."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(6, 0)),)
return _WAIT
grid = _rubble_row_grid(size=8, row=3)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
# The default foe sits at (0, 1), adjacent to the start: the run walks
# out of its threatened disk and provokes one (missed) AoO.
eng = _duo(policy, [10, 5, 1, 1], round_cap=1, hero=hero, grid=grid)
result = eng.run()
assert eng._states[0].pos == (2, 0)
assert "round 1 hero: run" in result.transcript
def test_move_can_enter_difficult_terrain_but_run_cannot() -> None:
"""Contrast: the same 3-cell geometry with `move` (6-cell budget) pays the
2x cost and DOES enter the rubble row — only the run refuses it."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", target_id="foe"),)
return _WAIT
grid = _rubble_row_grid(size=8, row=3)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (6, 0))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
eng.run()
# Move: (0,0)->(1,0)->(2,0) then the rubble step costs 2 of the 6-cell
# budget, so it crosses: the hero ends beyond row 3.
assert eng._states[0].pos[0] > 3
# ── (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, ...]:
if state.combatant.id == "hero":