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