fix(rules): constrain a run to a straight line
SRD Full-Round Actions - Run: 'you can move up to four times your speed in a straight line'. _execute_run reused the Dijkstra move machinery, so it turned corners around walls. It now walks the straight Bresenham line (_run_line_path) and stops at the first obstacle (impassable terrain, difficult terrain, or a blocking creature), mirroring the charge clear-path logic. Keeps the 4x budget, step-by-step AoO provocation, moved_this_turn and the running/Dex-loss flag; a destination the straight line cannot honour is rejected (unreachable_destination). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -814,27 +814,34 @@ Règles modélisées :
|
|||||||
une politique personnalisée).
|
une politique personnalisée).
|
||||||
- Course (`Action(kind="run")`, action à round complet, U5e) : le combattant
|
- Course (`Action(kind="run")`, action à round complet, U5e) : le combattant
|
||||||
se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé,
|
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
|
s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c).
|
||||||
réutilisant exactement la mécanique de `move` (descente du champ de coût
|
**S4a — la course est une ligne droite** (SRD « Full-Round Actions — Run » :
|
||||||
Dijkstra, pas à pas avec provocation d'AoO — courir ne protège pas des
|
« When you run, you can move up to four times your speed **in a straight
|
||||||
AoO), **sauf que la course ne traverse pas le terrain difficile**
|
line** ») : contrairement à `move` (descente Dijkstra qui contourne les
|
||||||
(conformance R2 — CRB « Movement, Position and Distance — Difficult
|
obstacles), la course suit la **ligne de Bresenham** entre la position
|
||||||
Terrain » : « You can't run or charge across difficult terrain ») : la
|
courante et la cible/destination, et **s'arrête au premier obstacle** —
|
||||||
descente refuse tout pas dont le footprint toucherait une case de coût > 1
|
terrain infranchissable, terrain difficile, ou créature bloquante — en
|
||||||
(mécanisme `avoid_difficult` de `_descend_path`, symétrique du refus de la
|
miroir de la logique de ligne dégagée de la charge (`_run_line_path`,
|
||||||
charge dans `_charge_path_clear`) ; le combattant **avance jusqu'à
|
`_charge_path_clear`). Le déplacement reste pas à pas avec **provocation
|
||||||
l'obstacle et s'y arrête** (comportement déterministe, retenu plutôt que
|
d'AoO** (courir ne protège pas des AoO), et conserve le budget 4×, le
|
||||||
le rejet de l'action) **quand la course est auto-dirigée**. À l'achèvement, le combattant est `running` jusqu'au
|
drapeau `moved_this_turn` et l'exclusivité avec les autres mouvements.
|
||||||
début de son prochain tour : il **perd son bonus de Dextérité à la CA**
|
Comme pour `move`, la course **traverse les cases des alliés** sans jamais
|
||||||
(résolu par le même chemin flat-footed/`denies_dex_to_ac` que les
|
y **finir** (SRD « Movement », conformance L5 — la queue du chemin est
|
||||||
conditions). Une `destination` hors budget 4× (ou invalide) est rejetée
|
tronquée) ; une créature hostile (la cible incluse) arrête la course une
|
||||||
(`unreachable_destination`) ; une `destination` joignable uniquement en
|
case avant, donc la course auto-dirigée **s'arrête adjacent à la cible**.
|
||||||
traversant le terrain difficile est **aussi rejetée** — le test de
|
La course **ne traverse pas le terrain difficile** (conformance R2 — CRB
|
||||||
portée (`_destination_reachable`) applique la même contrainte
|
« Difficult Terrain » : « You can't run or charge across difficult
|
||||||
`avoid_difficult` que la descente, donc le verdict et le chemin parcouru
|
terrain ») : elle avance jusqu'à l'obstacle et s'y arrête **quand elle est
|
||||||
sont cohérents (pas d'arrêt silencieux avant la destination) ; sans cible ni destination, rejet
|
auto-dirigée**. À l'achèvement, le combattant est `running` jusqu'au début
|
||||||
(`missing_target`). `default_policy` ne choisit pas la course (disponible
|
de son prochain tour : il **perd son bonus de Dextérité à la CA** (résolu
|
||||||
via une politique personnalisée).
|
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
|
- 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
|
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 —
|
avec tout autre mouvement (move, charge, retraite) dans le même tour —
|
||||||
|
|||||||
@@ -3982,19 +3982,25 @@ class CombatEngine:
|
|||||||
) -> ActionResult:
|
) -> ActionResult:
|
||||||
"""Execute the run action (PF1e, U5e): full-round movement at 4x speed.
|
"""Execute the run action (PF1e, U5e): full-round movement at 4x speed.
|
||||||
|
|
||||||
Reuses the move machinery: the Dijkstra descent toward an explicit
|
PF1e (SRD "Full-Round Actions — Run"): "When you run, you can move up
|
||||||
destination (U5c) or the resolved target, walked step-by-step with
|
to four times your speed in a straight line" — a run is NOT a ``move``:
|
||||||
the usual AoO provocation, under a 4x speed budget. PF1e forbids
|
it cannot turn a corner. The path is the straight Bresenham line from
|
||||||
running across difficult terrain (CRB "Movement, Position and
|
the combatant's position toward an explicit destination (U5c) or the
|
||||||
Distance — Difficult Terrain": "You can't run or charge across
|
resolved target, walked under a 4x speed budget with the usual step-by-
|
||||||
difficult terrain"), so the descent refuses every step into a
|
step AoO provocation, and truncated at the first obstacle (impassable
|
||||||
difficult cell: the run moves up to the obstacle and stops there
|
terrain, a difficult-terrain cell, or a blocking creature) exactly like
|
||||||
(deterministic; the same stop-short tolerance the greedy descent
|
the charge's clear-path rule (``_run_line_path``). PF1e forbids running
|
||||||
already applies to any other blockage). On completion the combatant
|
across difficult terrain (CRB "Movement, Position and Distance —
|
||||||
is ``running`` until the start of its next turn, which denies it its
|
Difficult Terrain": "You can't run or charge across difficult
|
||||||
Dexterity bonus to AC. An explicit destination that cannot be
|
terrain"), so a difficult cell stops the run short. An auto-directed
|
||||||
honoured within the 4x budget WITHOUT crossing difficult terrain (the
|
run advances until the obstacle and stops there (deterministic); a
|
||||||
same constraint the descent applies) is rejected with
|
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
|
``unreachable_destination``; with neither target nor destination the
|
||||||
U1 gate rejects the action with ``missing_target``.
|
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)")
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
||||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||||
if action.destination is not None:
|
if action.destination is not None:
|
||||||
reachable = self._destination_reachable(
|
if action.destination == state.pos:
|
||||||
state, action.destination, speed_factor=_RUN_SPEED_FACTOR,
|
path: list[Pos] = [] # already there: a valid zero-length run
|
||||||
friendly_passable=True, avoid_difficult=True,
|
else:
|
||||||
)
|
path = self._run_line_path(state, action.destination)
|
||||||
if not reachable:
|
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(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
|
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
|
||||||
@@ -4021,20 +4029,10 @@ class CombatEngine:
|
|||||||
return ActionResult(
|
return ActionResult(
|
||||||
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
|
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)
|
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._run_line_path(state, target.pos)
|
||||||
state, target.pos, speed_factor=_RUN_SPEED_FACTOR, avoid_difficult=True,
|
|
||||||
friendly_passable=True,
|
|
||||||
)
|
|
||||||
if self._walk_steps(state, path):
|
if self._walk_steps(state, path):
|
||||||
state.moved_this_turn = True
|
state.moved_this_turn = True
|
||||||
state.running = True
|
state.running = True
|
||||||
@@ -6943,6 +6941,46 @@ class CombatEngine:
|
|||||||
path.pop()
|
path.pop()
|
||||||
return path
|
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(
|
def _best_move_step(
|
||||||
self,
|
self,
|
||||||
state: CombatantState,
|
state: CombatantState,
|
||||||
|
|||||||
@@ -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))
|
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 ────────
|
# ── (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
|
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 ────────────────────────
|
# ── (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, ...]:
|
||||||
|
|||||||
Reference in New Issue
Block a user