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
+5 -1
View File
@@ -724,7 +724,11 @@ Règles modélisées :
- 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
avec tout autre mouvement (move, charge, retraite) dans le même tour —
tracked via `moved_this_turn`, réinitialisé au début du tour. `default_policy`
tracked via `moved_this_turn`, réinitialisé au début du tour. Conformance
L3 (SRD « Measuring Distance ») : « You can't move diagonally past a corner
(even by taking a 5-foot step) » — un pas diagonal dont les deux cases
adjacentes (flancs) ne sont pas toutes deux praticables est rejeté
(`unreachable_destination`), comme pour tout autre déplacement. `default_policy`
choisit `(5foot_step, full_attack)` quand un seul pas suffit à entrer en
portée.
- Distance : pénalité cumulative de −2 par incrément de portée complet au-delà
+5 -1
View File
@@ -5777,7 +5777,11 @@ class CombatEngine:
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
adjacent = self._grid.distance(state.pos, destination) == 1
free = self._cells_free(self._footprint_at(destination, state), state)
if not adjacent or not free:
# PF1e (SRD Measuring Distance): "You can't move diagonally past a
# corner (even by taking a 5-foot step)."
diagonal = state.pos[0] != destination[0] and state.pos[1] != destination[1]
corner_clear = not diagonal or self._grid.diagonal_allowed(state.pos, destination)
if not adjacent or not free or not corner_clear:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
+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 ─────────