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:
@@ -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, ...]:
|
||||
|
||||
Reference in New Issue
Block a user