From c96579a4850277658fa8d1bc4fe91741b4e00a5a Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 12 Sep 2026 08:54:36 +0200 Subject: [PATCH] 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 --- README.md | 6 +++++- src/pf1e_simulator/combat.py | 6 +++++- tests/test_move_destination.py | 38 ++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index de51b36..2b86255 100644 --- a/README.md +++ b/README.md @@ -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à diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index a4bb145..4c9c7f7 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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})" diff --git a/tests/test_move_destination.py b/tests/test_move_destination.py index 821c001..fe68834 100644 --- a/tests/test_move_destination.py +++ b/tests/test_move_destination.py @@ -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 ─────────