fix(rules): make the run reachability check avoid difficult terrain

PF1e (CRB Movement): "You can't run or charge across difficult
terrain". _execute_run checked the explicit destination against the full
cost field (which routes through difficult terrain at 2x) but descended
with avoid_difficult=True, so a destination beyond rubble was accepted
(ok=True) yet silently truncated at the obstacle. _destination_reachable
now takes the same avoid_difficult constraint (difficult cells removed
from the field; a destination whose footprint touches difficult terrain is
not a legal run endpoint either), so verdict and path agree. The test that
pinned the old silent-stop-short convention now expects
unreachable_destination; README updated.

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-12 11:53:27 +02:00
parent 8043dd393c
commit e3818fa6c9
3 changed files with 57 additions and 13 deletions
+6 -2
View File
@@ -737,11 +737,15 @@ Règles modélisées :
(mécanisme `avoid_difficult` de `_descend_path`, symétrique du refus de la (mécanisme `avoid_difficult` de `_descend_path`, symétrique du refus de la
charge dans `_charge_path_clear`) ; le combattant **avance jusqu'à charge dans `_charge_path_clear`) ; le combattant **avance jusqu'à
l'obstacle et s'y arrête** (comportement déterministe, retenu plutôt que 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 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** 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 (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 conditions). Une `destination` hors budget 4× (ou invalide) est rejetée
(`unreachable_destination`) ; sans cible ni destination, rejet (`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 (`missing_target`). `default_policy` ne choisit pas la course (disponible
via une politique personnalisée). 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
+23 -3
View File
@@ -3680,7 +3680,8 @@ class CombatEngine:
already applies to any other blockage). On completion the combatant already applies to any other blockage). On completion the combatant
is ``running`` until the start of its next turn, which denies it its is ``running`` until the start of its next turn, which denies it its
Dexterity bonus to AC. An explicit destination that cannot be Dexterity bonus to AC. An explicit destination that cannot be
honoured within the 4x budget is rejected with honoured within the 4x budget WITHOUT crossing difficult terrain (the
same constraint the descent applies) 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``.
""" """
@@ -3697,7 +3698,7 @@ class CombatEngine:
if action.destination is not None: if action.destination is not None:
reachable = self._destination_reachable( reachable = self._destination_reachable(
state, action.destination, speed_factor=_RUN_SPEED_FACTOR, state, action.destination, speed_factor=_RUN_SPEED_FACTOR,
friendly_passable=True, friendly_passable=True, avoid_difficult=True,
) )
if not reachable: if not reachable:
self._log( self._log(
@@ -6091,13 +6092,17 @@ class CombatEngine:
*, *,
speed_factor: int = 1, speed_factor: int = 1,
friendly_passable: bool = False, friendly_passable: bool = False,
avoid_difficult: bool = False,
) -> bool: ) -> bool:
"""True when destination is passable, unoccupied and within this speed. """True when destination is passable, unoccupied and within this speed.
``speed_factor`` widens the budget (run: 4x speed, U5e). ``speed_factor`` widens the budget (run: 4x speed, U5e).
``friendly_passable`` routes the cost field around allies' squares ``friendly_passable`` routes the cost field around allies' squares
(move/run may cross them, SRD Movement); the destination itself must (move/run may cross them, SRD Movement); the destination itself must
still be completely free. still be completely free. ``avoid_difficult`` removes difficult cells
from the cost field, mirroring the descent constraint of a run
(CRB: "You can't run or charge across difficult terrain") so the
reachability verdict and the walked path agree.
""" """
speed_cells = int( speed_cells = int(
_speed_ft(state) _speed_ft(state)
@@ -6109,10 +6114,25 @@ class CombatEngine:
return destination == state.pos return destination == state.pos
if not self._cells_free(self._footprint_at(destination, state), state): if not self._cells_free(self._footprint_at(destination, state), state):
return False return False
if avoid_difficult and self._footprint_touches_difficult(
self._footprint_at(destination, state)
):
return False
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state) blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
if avoid_difficult:
blocked = blocked | self._difficult_cells()
field = self._grid.reachable(state.pos, speed_cells, blocked) field = self._grid.reachable(state.pos, speed_cells, blocked)
return destination in field return destination in field
def _difficult_cells(self) -> frozenset[Pos]:
"""Every passable cell on the grid whose terrain costs more than one."""
return frozenset(
(row, col)
for row in range(self._grid.height)
for col in range(self._grid.width)
if self._is_difficult_cell((row, col))
)
def _walk_steps( def _walk_steps(
self, state: CombatantState, path: list[Pos] self, state: CombatantState, path: list[Pos]
) -> list[Pos]: ) -> list[Pos]:
+28 -8
View File
@@ -368,12 +368,13 @@ def test_run_stops_before_difficult_terrain_toward_target() -> None:
assert eng._states[0].pos == (2, 0) assert eng._states[0].pos == (2, 0)
def test_run_stops_before_difficult_terrain_to_destination() -> None: def test_run_beyond_difficult_terrain_to_destination_is_rejected() -> None:
"""Given: the same rubble row """Given: the same rubble row
When: hero runs to an explicit destination beyond it When: hero runs to an explicit destination ONLY reachable across it
Then: the descent stops in front of the rubble (the destination is Then: the destination is rejected (unreachable_destination) — the
reachable in budget, but the run cannot cross — it moves up to the reachability check uses the same avoid-difficult constraint as the
obstacle and stops).""" descent (CRB: "You can't run or charge across difficult terrain"), so
the verdict and the walked path agree instead of silently stopping short."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero": if state.combatant.id == "hero":
@@ -382,9 +383,28 @@ def test_run_stops_before_difficult_terrain_to_destination() -> None:
grid = _rubble_row_grid(size=8, row=3) grid = _rubble_row_grid(size=8, row=3)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0)) 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 eng = _duo(policy, [10, 5], round_cap=1, hero=hero, grid=grid)
# out of its threatened disk and provokes one (missed) AoO. result = eng.run()
eng = _duo(policy, [10, 5, 1, 1], round_cap=1, hero=hero, grid=grid) assert eng._states[0].pos == (0, 0)
assert "round 1 hero: run rejected (unreachable_destination)" in result.transcript
def test_run_destination_in_front_of_difficult_terrain_is_reachable() -> None:
"""Given: the same rubble row
When: hero runs to an explicit destination that avoids the rubble
Then: the run honours it (the field routes around difficult cells, the
budget is generous — the verdict is positive and the path arrives)."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(2, 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 at (0, 1) threatens the start: the run provokes one
# (missed) AoO on the first step — scripted as a natural 1.
eng = _duo(policy, [10, 5, 1], round_cap=1, hero=hero, grid=grid)
result = eng.run() result = eng.run()
assert eng._states[0].pos == (2, 0) assert eng._states[0].pos == (2, 0)
assert "round 1 hero: run" in result.transcript assert "round 1 hero: run" in result.transcript