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