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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user