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:
@@ -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})"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user