diff --git a/README.md b/README.md index f0eb3c2..8e38331 100644 --- a/README.md +++ b/README.md @@ -737,11 +737,15 @@ Règles modélisées : (mécanisme `avoid_difficult` de `_descend_path`, symétrique du refus de la charge dans `_charge_path_clear`) ; le combattant **avance jusqu'à 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** (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 - (`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 via une politique personnalisée). - Pas de placement (5-foot step) : action libre qui déplace le combattant d'une diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index e932961..eb4af9a 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -3680,7 +3680,8 @@ class CombatEngine: already applies to any other blockage). On completion the combatant is ``running`` until the start of its next turn, which denies it its 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 U1 gate rejects the action with ``missing_target``. """ @@ -3697,7 +3698,7 @@ class CombatEngine: if action.destination is not None: reachable = self._destination_reachable( state, action.destination, speed_factor=_RUN_SPEED_FACTOR, - friendly_passable=True, + friendly_passable=True, avoid_difficult=True, ) if not reachable: self._log( @@ -6091,13 +6092,17 @@ class CombatEngine: *, speed_factor: int = 1, friendly_passable: bool = False, + avoid_difficult: bool = False, ) -> bool: """True when destination is passable, unoccupied and within this speed. ``speed_factor`` widens the budget (run: 4x speed, U5e). ``friendly_passable`` routes the cost field around allies' squares (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_ft(state) @@ -6109,10 +6114,25 @@ class CombatEngine: return destination == state.pos if not self._cells_free(self._footprint_at(destination, state), state): 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) + if avoid_difficult: + blocked = blocked | self._difficult_cells() field = self._grid.reachable(state.pos, speed_cells, blocked) 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( self, state: CombatantState, path: list[Pos] ) -> list[Pos]: diff --git a/tests/test_aid_and_run.py b/tests/test_aid_and_run.py index 1e7cf86..af51d34 100644 --- a/tests/test_aid_and_run.py +++ b/tests/test_aid_and_run.py @@ -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