diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 3e32c93..7ae3e16 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -6898,8 +6898,17 @@ class CombatEngine: if self.has_condition_flag(state, "cant_move"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") return + # SRD "Take 5-Foot Step": "You can only take a 5-foot-step if your + # movement isn't hampered by difficult terrain or darkness." — a step + # out of OR into a difficult cell is blocked (darkness not modeled). + if self._footprint_touches_difficult(self._footprint_at(state.pos, state)): + self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)") + return path = self._move_path(state, target) start = state.pos + if self._footprint_touches_difficult(self._footprint_at(path[0], state)): + self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)") + return state.pos = path[0] state.moved_this_turn = True self._log( @@ -6921,7 +6930,14 @@ class CombatEngine: # 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: + # SRD "Take 5-Foot Step": "You can only take a 5-foot-step if your + # movement isn't hampered by difficult terrain or darkness." — a step + # out of OR into a difficult cell is blocked (darkness not modeled). + step_clear = not ( + self._footprint_touches_difficult(self._footprint_at(state.pos, state)) + or self._footprint_touches_difficult(self._footprint_at(destination, state)) + ) + if not adjacent or not free or not corner_clear or not step_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 fef1839..afa9fa0 100644 --- a/tests/test_move_destination.py +++ b/tests/test_move_destination.py @@ -450,3 +450,74 @@ def test_move_with_target_auto_paths_as_before() -> None: assert eng._states[0].last_action_results == [ ActionResult(kind="move", ok=True, reason="ok") ] + + +# ── (g) 5-foot step blocked by difficult terrain ──────────────────────────── + + +def _rubble_adjacent_grid() -> Grid: + """8x8 grid where (0, 1) is difficult terrain (rubble, cost 2).""" + rows = [".~" + "." * 6] + ["." * 8] * 7 + legend = { + ".": TerrainType(type="floor", move_cost=1), + "~": TerrainType(type="rubble", move_cost=2), + } + return Grid.from_spec(MapSpec(name="rubble", terrain=tuple(rows), legend=legend)) + + +def test_step5_to_difficult_destination_is_rejected() -> None: + """SRD Take 5-Foot Step: hampered by difficult terrain blocks the step.""" + + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="5foot_step", destination=(0, 1)),) + return _WAIT + + grid = _rubble_adjacent_grid() + eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(0, 4), grid=grid) + t = eng.run().transcript + assert eng._states[0].pos == (0, 0) + assert "round 1 hero: 5foot_step rejected (unreachable_destination)" in t + + +def test_step5_auto_path_to_difficult_is_blocked() -> None: + """SRD Take 5-Foot Step: auto-directed step into difficult terrain blocked.""" + + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="5foot_step", target_id="foe"),) + return _WAIT + + legend = { + ".": TerrainType(type="floor", move_cost=1), + "~": TerrainType(type="rubble", move_cost=2), + "#": TerrainType(type="wall", move_cost=None, blocks_los=True), + } + # Hero at (0,0), wall at (0,1) blocks east, rubble at (1,0) is the only exit. + # Foe at (2,0) -- the greedy walk must go through (1,0) rubble. + rows = [".#" + "." * 6, "~" + "." * 7] + ["." * 8] * 6 + grid = Grid.from_spec(MapSpec(name="funnel", terrain=tuple(rows), legend=legend)) + eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(2, 0), grid=grid) + t = eng.run().transcript + assert eng._states[0].pos == (0, 0) + assert "round 1 hero: wait (cannot step)" in t + + +def test_step5_from_difficult_origin_is_rejected() -> None: + """SRD Take 5-Foot Step: origin on difficult terrain also blocks the step.""" + + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="5foot_step", destination=(1, 1)),) + return _WAIT + + legend = { + ".": TerrainType(type="floor", move_cost=1), + "~": TerrainType(type="rubble", move_cost=2), + } + rows = ["~" + "." * 7] + ["." * 8] * 7 + grid = Grid.from_spec(MapSpec(name="rubble", terrain=tuple(rows), legend=legend)) + eng = _hero_vs_foe(policy, [10, 5], round_cap=1, foe_pos=(0, 4), grid=grid) + t = eng.run().transcript + assert eng._states[0].pos == (0, 0) + assert "round 1 hero: 5foot_step rejected (unreachable_destination)" in t