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
+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)
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
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)."""
When: hero runs to an explicit destination ONLY reachable across it
Then: the destination is rejected (unreachable_destination) — the
reachability check uses the same avoid-difficult constraint as the
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, ...]:
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)
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)
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
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()
assert eng._states[0].pos == (2, 0)
assert "round 1 hero: run" in result.transcript