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