diff --git a/README.md b/README.md index 52612a3..1681092 100644 --- a/README.md +++ b/README.md @@ -814,27 +814,34 @@ Règles modélisées : une politique personnalisée). - Course (`Action(kind="run")`, action à round complet, U5e) : le combattant se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé, - 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), **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) **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`) ; 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). + s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c). + **S4a — la course est une ligne droite** (SRD « Full-Round Actions — Run » : + « When you run, you can move up to four times your speed **in a straight + line** ») : contrairement à `move` (descente Dijkstra qui contourne les + obstacles), la course suit la **ligne de Bresenham** entre la position + courante et la cible/destination, et **s'arrête au premier obstacle** — + terrain infranchissable, terrain difficile, ou créature bloquante — en + miroir de la logique de ligne dégagée de la charge (`_run_line_path`, + `_charge_path_clear`). Le déplacement reste pas à pas avec **provocation + d'AoO** (courir ne protège pas des AoO), et conserve le budget 4×, le + drapeau `moved_this_turn` et l'exclusivité avec les autres mouvements. + Comme pour `move`, la course **traverse les cases des alliés** sans jamais + y **finir** (SRD « Movement », conformance L5 — la queue du chemin est + tronquée) ; une créature hostile (la cible incluse) arrête la course une + case avant, donc la course auto-dirigée **s'arrête adjacent à la cible**. + La course **ne traverse pas le terrain difficile** (conformance R2 — CRB + « Difficult Terrain » : « You can't run or charge across difficult + terrain ») : elle avance jusqu'à l'obstacle et s'y arrête **quand elle 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` que la ligne droite ne peut pas honorer dans le budget 4× + (hors portée, bloquée par un obstacle/une créature, ou joignable uniquement + en tournant un coin ou en traversant le terrain difficile) est **rejetée** + (`unreachable_destination`) — le verdict et le chemin parcouru sont + cohérents (pas de contournement silencieux) ; 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 case vers l'ennemi le plus proche sans provoquer d'AoO. Exclusivité mutuelle avec tout autre mouvement (move, charge, retraite) dans le même tour — diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 783928f..4c028a3 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -3982,19 +3982,25 @@ class CombatEngine: ) -> ActionResult: """Execute the run action (PF1e, U5e): full-round movement at 4x speed. - 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. 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 WITHOUT crossing difficult terrain (the - same constraint the descent applies) is rejected with + PF1e (SRD "Full-Round Actions — Run"): "When you run, you can move up + to four times your speed in a straight line" — a run is NOT a ``move``: + it cannot turn a corner. The path is the straight Bresenham line from + the combatant's position toward an explicit destination (U5c) or the + resolved target, walked under a 4x speed budget with the usual step-by- + step AoO provocation, and truncated at the first obstacle (impassable + terrain, a difficult-terrain cell, or a blocking creature) exactly like + the charge's clear-path rule (``_run_line_path``). PF1e forbids running + across difficult terrain (CRB "Movement, Position and Distance — + Difficult Terrain": "You can't run or charge across difficult + terrain"), so a difficult cell stops the run short. An auto-directed + run advances until the obstacle and stops there (deterministic); a + hostile creature (the target included) halts it one cell short, so it + ends adjacent to the target. Friendly squares are still crossed, never + ended on (SRD Movement, conformance L5). 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 the straight line + cannot honour within the 4x budget (blocked by terrain, difficult + terrain, a creature, or out of range) is rejected with ``unreachable_destination``; with neither target nor destination the U1 gate rejects the action with ``missing_target``. """ @@ -4009,11 +4015,13 @@ class CombatEngine: self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") return ActionResult(kind=kind, ok=True, reason=_REASON_OK) if action.destination is not None: - reachable = self._destination_reachable( - state, action.destination, speed_factor=_RUN_SPEED_FACTOR, - friendly_passable=True, avoid_difficult=True, - ) - if not reachable: + if action.destination == state.pos: + path: list[Pos] = [] # already there: a valid zero-length run + else: + path = self._run_line_path(state, action.destination) + if not path or path[-1] != action.destination: + path = [] # the straight line could not honour it + if not path and action.destination != state.pos: self._log( f"round {self._current_round} {state.combatant.id}: " f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})" @@ -4021,20 +4029,10 @@ class CombatEngine: return ActionResult( kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION ) - path = self._descend_path( - state, - action.destination, - speed_factor=_RUN_SPEED_FACTOR, - avoid_difficult=True, - friendly_passable=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, avoid_difficult=True, - friendly_passable=True, - ) + path = self._run_line_path(state, target.pos) if self._walk_steps(state, path): state.moved_this_turn = True state.running = True @@ -6943,6 +6941,46 @@ class CombatEngine: path.pop() return path + def _run_line_path(self, state: CombatantState, goal: Pos) -> list[Pos]: + """Straight-line run path toward ``goal``, truncated at the first obstacle. + + PF1e (SRD "Full-Round Actions — Run"): "When you run, you can move up + to four times your speed in a straight line" — a run CANNOT turn a + corner the way a Dijkstra ``move`` does. The path is the Bresenham + line from the combatant's anchor toward ``goal``, walked under the 4x + speed budget and stopped at the first obstacle, mirroring the charge's + straight-line clear-path logic (CRB "Charge"): impassable terrain, a + difficult-terrain cell (CRB "Difficult Terrain": "You can't run or + charge across difficult terrain"), or a blocking creature. A hostile + creature (including the target itself) halts the walk one cell short, + so an auto-directed run ends adjacent to the target. Friendly squares + are still crossed (SRD Movement, conformance L5) but never ended on — + the tail is trimmed like ``_descend_path``. + """ + speed_mult = self._get_speed_mult(state) + budget = int( + _speed_ft(state) // _SQUARE_FT * speed_mult * _RUN_SPEED_FACTOR + ) + if budget <= 0: + return [] + path: list[Pos] = [] + current = state.pos + for nxt in _bresenham_line(state.pos, goal): + footprint = self._footprint_at(nxt, state) + if self._footprint_touches_difficult(footprint): + break + if not self._cells_walkable(footprint, state): + break + step = self._grid.step_cost(current, nxt, 0) + if step > budget: + break + path.append(nxt) + current = nxt + budget -= step + while path and not self._cells_free(self._footprint_at(path[-1], state), state): + path.pop() + return path + def _best_move_step( self, state: CombatantState, diff --git a/tests/test_aid_and_run.py b/tests/test_aid_and_run.py index af51d34..2228a7c 100644 --- a/tests/test_aid_and_run.py +++ b/tests/test_aid_and_run.py @@ -127,6 +127,24 @@ def _rubble_row_grid(size: int = 16, row: int = 5) -> Grid: return Grid.from_spec(MapSpec(name="rubble", terrain=terrain, legend=legend)) +def _walled_grid(size: int = 16) -> Grid: + """Open grid with a wall block at rows 1-4, col 5 (a straight-line barrier). + + A hero at (0, 0) running toward a foe at (2, 8) would have to turn the + corner around the wall (a Dijkstra ``move`` does); the straight-line run + must instead stop at the wall face. + """ + terrain = [] + for r in range(size): + row = ["#" if (1 <= r <= 4 and c == 5) else "." for c in range(size)] + terrain.append("".join(row)) + legend = { + ".": TerrainType(type="floor", move_cost=1), + "#": TerrainType(type="wall", move_cost=None, blocks_los=True), + } + return Grid.from_spec(MapSpec(name="walled", terrain=tuple(terrain), legend=legend)) + + # ── (a) aid (attack): +2 on the ally's next attack, consumed one-shot ──────── @@ -429,6 +447,87 @@ def test_move_can_enter_difficult_terrain_but_run_cannot() -> None: assert eng._states[0].pos[0] > 3 +# ── (d3) S4a: a run is a straight line (SRD Full-Round Actions — Run) ───────── + + +def test_run_does_not_turn_a_corner_around_a_wall() -> None: + """Given: hero at (0,0), a wall column at col 5 (rows 1-4), foe at (2,8) + When: hero runs toward the foe (a Dijkstra move would bend around the wall) + Then: the run follows the straight Bresenham line and stops at the wall + face — it does NOT turn the corner (SRD Run: "move up to four times your + speed in a straight line").""" + + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="run", target_id="foe"),) + return _WAIT + + grid = _walled_grid(size=12) + hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0)) + foe = make_state(_combatant("foe"), "monsters", (2, 8)) + eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid) + eng.run() + hero_state = eng._states[0] + # The straight line (0,0)->(2,8) crosses col 5 at rows 1-2 (both wall), so + # the run halts before the wall face and never reaches or bends past it. + assert hero_state.pos[1] < 5 + assert hero_state.running is True + # Contrast: a plain move DOES route around the wall (bends the corner). + hero2 = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0)) + foe2 = make_state(_combatant("foe"), "monsters", (2, 8)) + eng2 = _duo( + lambda _e, s: ( + (Action(kind="move", target_id="foe"),) + if s.combatant.id == "hero" else _WAIT + ), + [10, 5], round_cap=1, hero=hero2, foe=foe2, grid=grid, + ) + eng2.run() + assert eng2._states[0].pos[1] > 5 # the move got around to the far side + + +def test_clear_straight_run_still_works() -> None: + """Given: an open grid, hero (0,0), foe far along a clear straight line + When: hero runs toward the foe + Then: the straight-line run reaches adjacent to the target (unchanged + behaviour on an unobstructed line).""" + + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="run", target_id="foe"),) + return _WAIT + + grid = _open_grid(16) + hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0)) + foe = make_state(_combatant("foe"), "monsters", (0, 15)) + eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid) + eng.run() + hero_state = eng._states[0] + # 4x speed = 24 cells; the 15-cell straight run reaches (0,14), adjacent. + assert hero_state.pos == (0, 14) + assert hero_state.running is True + + +def test_run_destination_around_a_corner_is_rejected() -> None: + """Given: the wall column, hero (0,0) + When: hero runs to an explicit destination only reachable by turning the + corner (2,8) + Then: the straight line cannot honour it, so the destination is rejected + (unreachable_destination) rather than the run bending around the wall.""" + + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="run", destination=(2, 8)),) + return _WAIT + + grid = _walled_grid(size=12) + hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0)) + 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 + + # ── (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, ...]: