conformance: block 5-foot step on difficult terrain (SRD Take 5-Foot Step)

A 5-foot step is hampered by difficult terrain: stepping out of OR into a
difficult cell is blocked. _step5 (auto-directed) logs 'wait (cannot step)';
_step5_to (explicit destination) rejects with unreachable_destination.
Engine-level tests cover destination, auto-path, and origin cases.
This commit is contained in:
2026-09-16 13:47:10 +02:00
parent 7fc7a1d6ec
commit 437507ee2c
2 changed files with 88 additions and 1 deletions
+17 -1
View File
@@ -6898,8 +6898,17 @@ class CombatEngine:
if self.has_condition_flag(state, "cant_move"): if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return 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) path = self._move_path(state, target)
start = state.pos 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.pos = path[0]
state.moved_this_turn = True state.moved_this_turn = True
self._log( self._log(
@@ -6921,7 +6930,14 @@ class CombatEngine:
# corner (even by taking a 5-foot step)." # corner (even by taking a 5-foot step)."
diagonal = state.pos[0] != destination[0] and state.pos[1] != destination[1] diagonal = state.pos[0] != destination[0] and state.pos[1] != destination[1]
corner_clear = not diagonal or self._grid.diagonal_allowed(state.pos, destination) 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( 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})"
+71
View File
@@ -450,3 +450,74 @@ def test_move_with_target_auto_paths_as_before() -> None:
assert eng._states[0].last_action_results == [ assert eng._states[0].last_action_results == [
ActionResult(kind="move", ok=True, reason="ok") 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