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 - 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 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 — 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 choisit `(5foot_step, full_attack)` quand un seul pas suffit à entrer en
portée. portée.
- Distance : pénalité cumulative de −2 par incrément de portée complet au-delà - 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) return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
adjacent = self._grid.distance(state.pos, destination) == 1 adjacent = self._grid.distance(state.pos, destination) == 1
free = self._cells_free(self._footprint_at(destination, state), state) 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( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})" 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) 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 ───────── # ── (e) U1 preserved: neither target nor destination → missing_target ─────────