fix(rules): forbid a diagonal 5-foot step past a corner

PF1e (SRD Measuring Distance): "You can't move diagonally past a corner (even by taking a 5-foot step)." _step5_to now rejects a diagonal destination whose two flanking squares are not both passable, reusing Grid.diagonal_allowed - the same corner rule the regular movement already applies. README updated.

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 08:54:36 +02:00
parent 98b61c6cf8
commit c96579a485
3 changed files with 48 additions and 2 deletions
+38
View File
@@ -246,6 +246,44 @@ def test_step5_to_occupied_destination_is_rejected() -> None:
assert eng._states[0].pos == (0, 0)
def _corner_grid() -> Grid:
"""Open 8x8 floor with a wall at (1, 0) — a corner for the (0,0)->(1,1) step."""
rows = ["." * 8, "#" + "." * 7] + ["." * 8] * 6
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
spec = MapSpec(name="corner", terrain=tuple(rows), legend=legend)
return Grid.from_spec(spec)
def test_step5_diagonal_past_corner_is_rejected() -> None:
# SRD Measuring Distance: "You can't move diagonally past a corner
# (even by taking a 5-foot step)." Wall at (1, 0) blocks the flank.
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="5foot_step", destination=(1, 1)),)
return _WAIT
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(7, 7), grid=_corner_grid())
t = eng.run().transcript
assert "round 1 hero: 5foot_step rejected (unreachable_destination)" in t
assert eng._states[0].pos == (0, 0)
def test_step5_diagonal_with_clear_flanks_is_allowed() -> None:
# The same diagonal step on an open grid (both flanks passable) is legal.
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="5foot_step", destination=(1, 1)),)
return _WAIT
eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(7, 7))
t = eng.run().transcript
assert "round 1 hero: 5ft step (0,0)->(1,1)" in t
assert eng._states[0].pos == (1, 1)
# ── (e) U1 preserved: neither target nor destination → missing_target ─────────