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:
@@ -693,10 +693,17 @@ Règles modélisées :
|
|||||||
s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c), en
|
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
|
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
|
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
|
AoO), **sauf que la course ne traverse pas le terrain difficile**
|
||||||
prochain tour : il **perd son bonus de Dextérité à la CA** (résolu par le
|
(conformance R2 — CRB « Movement, Position and Distance — Difficult
|
||||||
même chemin flat-footed/`denies_dex_to_ac` que les conditions). Une
|
Terrain » : « You can't run or charge across difficult terrain ») : la
|
||||||
`destination` hors budget 4× (ou invalide) est rejetée
|
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
|
(`unreachable_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).
|
||||||
|
|||||||
@@ -3473,10 +3473,16 @@ class CombatEngine:
|
|||||||
|
|
||||||
Reuses the move machinery: the Dijkstra descent toward an explicit
|
Reuses the move machinery: the Dijkstra descent toward an explicit
|
||||||
destination (U5c) or the resolved target, walked step-by-step with
|
destination (U5c) or the resolved target, walked step-by-step with
|
||||||
the usual AoO provocation, under a 4x speed budget. On completion the
|
the usual AoO provocation, under a 4x speed budget. PF1e forbids
|
||||||
combatant is ``running`` until the start of its next turn, which
|
running across difficult terrain (CRB "Movement, Position and
|
||||||
denies it its Dexterity bonus to AC. An explicit destination that
|
Distance — Difficult Terrain": "You can't run or charge across
|
||||||
cannot be honoured within the 4x budget is rejected with
|
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
|
``unreachable_destination``; with neither target nor destination the
|
||||||
U1 gate rejects the action with ``missing_target``.
|
U1 gate rejects the action with ``missing_target``.
|
||||||
"""
|
"""
|
||||||
@@ -3503,13 +3509,16 @@ class CombatEngine:
|
|||||||
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
|
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
|
||||||
)
|
)
|
||||||
path = self._descend_path(
|
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)
|
self._walk_steps(state, path)
|
||||||
state.moved_this_turn = True
|
state.moved_this_turn = True
|
||||||
elif target is not None:
|
elif target is not None:
|
||||||
path = self._descend_path(
|
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):
|
if self._walk_steps(state, path):
|
||||||
state.moved_this_turn = True
|
state.moved_this_turn = True
|
||||||
@@ -5720,11 +5729,19 @@ class CombatEngine:
|
|||||||
return self._descend_path(state, target.pos)
|
return self._descend_path(state, target.pos)
|
||||||
|
|
||||||
def _descend_path(
|
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]:
|
) -> list[Pos]:
|
||||||
"""Greedy walk of the anchor over the cost field from ``anchor``.
|
"""Greedy walk of the anchor over the cost field from ``anchor``.
|
||||||
|
|
||||||
``speed_factor`` widens the movement budget (run: 4x speed, U5e).
|
``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_mult = self._get_speed_mult(state)
|
||||||
speed_cells = int(
|
speed_cells = int(
|
||||||
@@ -5743,7 +5760,9 @@ class CombatEngine:
|
|||||||
current = state.pos
|
current = state.pos
|
||||||
budget = speed_cells
|
budget = speed_cells
|
||||||
while budget > 0:
|
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:
|
if best is None:
|
||||||
break
|
break
|
||||||
_, step_cost, nxt = best
|
_, step_cost, nxt = best
|
||||||
@@ -5758,6 +5777,8 @@ class CombatEngine:
|
|||||||
current: Pos,
|
current: Pos,
|
||||||
to_target: dict[Pos, int],
|
to_target: dict[Pos, int],
|
||||||
budget: int,
|
budget: int,
|
||||||
|
*,
|
||||||
|
avoid_difficult: bool = False,
|
||||||
) -> tuple[int, int, Pos] | None:
|
) -> tuple[int, int, Pos] | None:
|
||||||
"""Pick the adjacent anchor that best descends the cost field toward the target."""
|
"""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)
|
current_cost = self._footprint_cost(self._footprint_at(current, state), to_target)
|
||||||
@@ -5770,6 +5791,8 @@ class CombatEngine:
|
|||||||
footprint = self._footprint_at(nxt, state)
|
footprint = self._footprint_at(nxt, state)
|
||||||
if not self._cells_free(footprint, state):
|
if not self._cells_free(footprint, state):
|
||||||
continue
|
continue
|
||||||
|
if avoid_difficult and self._footprint_touches_difficult(footprint):
|
||||||
|
continue
|
||||||
nxt_cost = self._footprint_cost(footprint, to_target)
|
nxt_cost = self._footprint_cost(footprint, to_target)
|
||||||
if nxt_cost is None or nxt_cost >= current_cost:
|
if nxt_cost is None or nxt_cost >= current_cost:
|
||||||
continue
|
continue
|
||||||
@@ -5781,6 +5804,17 @@ class CombatEngine:
|
|||||||
best = (score, step, nxt)
|
best = (score, step, nxt)
|
||||||
return best
|
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]:
|
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
|
||||||
"""Movement path away from ``threat``, up to double speed.
|
"""Movement path away from ``threat``, up to double speed.
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,16 @@ def _open_grid(size: int = 16) -> Grid:
|
|||||||
return Grid.from_spec(spec)
|
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 ────────
|
# ── (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)
|
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 ────────────────────────
|
# ── (e) a running combatant loses its Dex bonus to AC ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_running_combatant_is_denied_dex_to_ac() -> None:
|
def test_running_combatant_is_denied_dex_to_ac() -> None:
|
||||||
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":
|
||||||
|
|||||||
Reference in New Issue
Block a user