From b453b19be21c6b6d07608a72e60d70c8599868d7 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 22:16:54 +0200 Subject: [PATCH] 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 --- README.md | 15 +++++-- src/pf1e_simulator/combat.py | 50 +++++++++++++++++++---- tests/test_aid_and_run.py | 77 +++++++++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2e42afe..4f01a85 100644 --- a/README.md +++ b/README.md @@ -693,10 +693,17 @@ Règles modélisées : s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c), en réutilisant exactement la mécanique de `move` (descente du champ de coût Dijkstra, pas à pas avec provocation d'AoO — courir ne protège pas des - AoO). À 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 + AoO), **sauf que la course ne traverse pas le terrain difficile** + (conformance R2 — CRB « Movement, Position and Distance — Difficult + Terrain » : « You can't run or charge across difficult terrain ») : la + descente refuse tout pas dont le footprint toucherait une case de coût > 1 + (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 + 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 (`missing_target`). `default_policy` ne choisit pas la course (disponible via une politique personnalisée). diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 6f72c6d..ced4cb0 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -3473,10 +3473,16 @@ class CombatEngine: Reuses the move machinery: the Dijkstra descent toward an explicit destination (U5c) or the resolved target, walked step-by-step with - the usual AoO provocation, under a 4x speed budget. 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 + the usual AoO provocation, under a 4x speed budget. PF1e forbids + running across difficult terrain (CRB "Movement, Position and + Distance — Difficult Terrain": "You can't run or charge across + difficult terrain"), so the descent refuses every step into a + difficult cell: the run moves up to the obstacle and stops there + (deterministic; the same stop-short tolerance the greedy descent + 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 ``unreachable_destination``; with neither target nor destination the U1 gate rejects the action with ``missing_target``. """ @@ -3503,13 +3509,16 @@ class CombatEngine: kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION ) path = self._descend_path( - state, action.destination, speed_factor=_RUN_SPEED_FACTOR + state, + action.destination, + speed_factor=_RUN_SPEED_FACTOR, + avoid_difficult=True, ) self._walk_steps(state, path) state.moved_this_turn = True elif target is not None: path = self._descend_path( - state, target.pos, speed_factor=_RUN_SPEED_FACTOR + state, target.pos, speed_factor=_RUN_SPEED_FACTOR, avoid_difficult=True ) if self._walk_steps(state, path): state.moved_this_turn = True @@ -5720,11 +5729,19 @@ class CombatEngine: return self._descend_path(state, target.pos) def _descend_path( - self, state: CombatantState, anchor: Pos, *, speed_factor: int = 1 + self, + state: CombatantState, + anchor: Pos, + *, + speed_factor: int = 1, + avoid_difficult: bool = False, ) -> list[Pos]: """Greedy walk of the anchor over the cost field from ``anchor``. ``speed_factor`` widens the movement budget (run: 4x speed, U5e). + ``avoid_difficult`` excludes difficult-terrain cells from the walk + (PF1e: a run cannot cross difficult terrain), so the walk stops in + front of the obstacle. """ speed_mult = self._get_speed_mult(state) speed_cells = int( @@ -5743,7 +5760,9 @@ class CombatEngine: current = state.pos budget = speed_cells while budget > 0: - best = self._best_move_step(state, current, to_target, budget) + best = self._best_move_step( + state, current, to_target, budget, avoid_difficult=avoid_difficult + ) if best is None: break _, step_cost, nxt = best @@ -5758,6 +5777,8 @@ class CombatEngine: current: Pos, to_target: dict[Pos, int], budget: int, + *, + avoid_difficult: bool = False, ) -> tuple[int, int, Pos] | None: """Pick the adjacent anchor that best descends the cost field toward the target.""" current_cost = self._footprint_cost(self._footprint_at(current, state), to_target) @@ -5770,6 +5791,8 @@ class CombatEngine: footprint = self._footprint_at(nxt, state) if not self._cells_free(footprint, state): continue + if avoid_difficult and self._footprint_touches_difficult(footprint): + continue nxt_cost = self._footprint_cost(footprint, to_target) if nxt_cost is None or nxt_cost >= current_cost: continue @@ -5781,6 +5804,17 @@ class CombatEngine: best = (score, step, nxt) return best + def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool: + """True if any footprint cell is difficult terrain (move cost > 1).""" + return any(self._is_difficult_cell(cell) for cell in footprint) + + def _is_difficult_cell(self, pos: Pos) -> bool: + """True for a passable cell whose terrain costs more than one square.""" + if not self._grid.in_bounds(pos): + return False + terrain = self._grid.terrain_at(pos) + return terrain.move_cost is not None and terrain.move_cost > 1 + def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]: """Movement path away from ``threat``, up to double speed. diff --git a/tests/test_aid_and_run.py b/tests/test_aid_and_run.py index 545ff65..1e7cf86 100644 --- a/tests/test_aid_and_run.py +++ b/tests/test_aid_and_run.py @@ -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":