From e1c9e78b4e0a6224e3e117b92706fe46070d92f7 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 12 Sep 2026 10:48:12 +0200 Subject: [PATCH] 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 --- src/pf1e_simulator/combat.py | 6 ++++++ tests/test_combat.py | 13 ++++++++----- tests/test_move_destination.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 83a7c5c..afb0a06 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -6130,6 +6130,12 @@ class CombatEngine: fits = self._cells_walkable if friendly_passable else self._cells_free for d_row, d_col in _STEP_DELTAS: 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) if not fits(footprint, state): continue diff --git a/tests/test_combat.py b/tests/test_combat.py index eabe218..a49953a 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -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) target = make_combatant("target", speed=0) 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) result = engine.run() 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 == ( "initiative: archer d20=10+0=10", "initiative: target d20=9+0=9", - "round 1 archer: 5ft step (0,1)->(1,2)", - "round 1 archer: short bow vs target d20=5+4=9 AC 13 -> MISS", + "round 1 archer: 5ft step (0,1)->(1,1)", + "round 1 archer: short bow vs target d20=5+4=9 AC 17 -> MISS", "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 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", "battle over: players win in 3 rounds", ) diff --git a/tests/test_move_destination.py b/tests/test_move_destination.py index 4ba5643..fef1839 100644 --- a/tests/test_move_destination.py +++ b/tests/test_move_destination.py @@ -378,6 +378,37 @@ def test_step5_diagonal_with_clear_flanks_is_allowed() -> None: 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 ─────────