fix(rules): stop auto-directed movement cutting corners

PF1e (CRB, Measuring Distance): "You can't move diagonally past a corner
(even by taking a 5-foot step)". _best_move_step (the greedy descent behind
the auto-directed move/run/5-foot-step) now enforces the same
diagonal_allowed corner check _step5_to and _best_flee_step already apply.

tests/test_combat.py::test_ranged_no_line_of_effect_moves_around_wall
pinned the old corner-cutting step (0,1)->(1,2) past the wall at (0,2);
re-scripted for the legal (0,1)->(1,1) path (wall cover now applies).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-12 10:48:12 +02:00
parent 4b4e6f2726
commit e1c9e78b4e
3 changed files with 45 additions and 5 deletions
+6
View File
@@ -6130,6 +6130,12 @@ class CombatEngine:
fits = self._cells_walkable if friendly_passable else self._cells_free fits = self._cells_walkable if friendly_passable else self._cells_free
for d_row, d_col in _STEP_DELTAS: for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col) nxt = (row + d_row, col + d_col)
# PF1e (CRB, Measuring Distance): "You can't move diagonally past
# a corner (even by taking a 5-foot step)" — same corner rule the
# explicit step (``_step5_to``) and flee (``_best_flee_step``)
# paths already enforce.
if not self._grid.diagonal_allowed(current, nxt):
continue
footprint = self._footprint_at(nxt, state) footprint = self._footprint_at(nxt, state)
if not fits(footprint, state): if not fits(footprint, state):
continue continue
+8 -5
View File
@@ -558,19 +558,22 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
target = make_combatant("target", speed=0) target = make_combatant("target", speed=0)
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))] states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
rng = ScriptedRng([10, 9, 5, 15, 3, 10, 4, 12, 2, 8, 2, 14, 1]) rng = ScriptedRng([10, 9, 5, 13, 6, 15, 4])
engine = CombatEngine(rng, grid, states, round_cap=4) engine = CombatEngine(rng, grid, states, round_cap=4)
result = engine.run() result = engine.run()
assert result.winner == "players" assert result.winner == "players"
# SRD Measuring Distance corner rule: the (0,1)->(1,2) diagonal is flank-
# blocked by the wall at (0,2), so the auto step goes to (1,1) instead —
# and the wall keeps granting cover (+4 AC) from that square.
assert result.transcript == ( assert result.transcript == (
"initiative: archer d20=10+0=10", "initiative: archer d20=10+0=10",
"initiative: target d20=9+0=9", "initiative: target d20=9+0=9",
"round 1 archer: 5ft step (0,1)->(1,2)", "round 1 archer: 5ft step (0,1)->(1,1)",
"round 1 archer: short bow vs target d20=5+4=9 AC 13 -> MISS", "round 1 archer: short bow vs target d20=5+4=9 AC 17 -> MISS",
"round 1 target: wait", "round 1 target: wait",
"round 2 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)", "round 2 archer: short bow vs target d20=13+4=17 AC 17 -> HIT 6 damage (6->0)",
"round 2 target: wait", "round 2 target: wait",
"round 3 archer: short bow vs target d20=10+4=14 AC 13 -> HIT 4 damage (3->-1)", "round 3 archer: short bow vs target d20=15+4=19 AC 17 -> HIT 4 damage (0->-4)",
"round 3 archer: target down", "round 3 archer: target down",
"battle over: players win in 3 rounds", "battle over: players win in 3 rounds",
) )
+31
View File
@@ -378,6 +378,37 @@ def test_step5_diagonal_with_clear_flanks_is_allowed() -> None:
assert eng._states[0].pos == (1, 1) assert eng._states[0].pos == (1, 1)
def test_auto_move_does_not_cut_wall_corner() -> None:
# SRD Measuring Distance: "You can't move diagonally past a corner."
# Wall at (1, 0) flanks the (0,0)->(1,1) diagonal: the auto-directed
# move must walk around it (via (0, 1)), never through the corner.
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", target_id="foe"),)
return _WAIT
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(2, 2), grid=_corner_grid())
t = eng.run().transcript
move_line = next(ln for ln in t if "hero: move" in ln)
assert "(0,0)->(1,1)" not in move_line # no corner cut
assert "(0,0)->(0,1)" in move_line # went around the wall
def test_auto_step5_does_not_cut_wall_corner() -> None:
# The auto 5-foot step takes its single step from the same greedy walk,
# so it too must refuse the corner-cutting diagonal toward the foe.
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="5foot_step", target_id="foe"),)
return _WAIT
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(2, 2), grid=_corner_grid())
t = eng.run().transcript
assert "round 1 hero: 5ft step (0,0)->(1,1)" not in t
assert "round 1 hero: 5ft step (0,0)->(0,1)" in t
assert eng._states[0].pos == (0, 1)
# ── (e) U1 preserved: neither target nor destination → missing_target ───────── # ── (e) U1 preserved: neither target nor destination → missing_target ─────────