fix(rules): allow movement through friendly squares

PF1e (SRD Movement, Position and Distance): "You can move through a square occupied by a friendly character, unless you are charging." The move and run descents now route their Dijkstra cost field and greedy steps over hostile-only blocking (_blocked_hostile), so allies' squares are passable; a friendly square may only be entered when a free cell descends from it and the path tail is trimmed, so movement never ENDS on an occupied cell. Charge, withdraw, the auto 5-foot step and the Observation cost fields keep full blocking (documented). Corridor tests added; the Combat Reflexes multi-AoO transcript is unchanged. 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:
2026-09-12 09:18:29 +02:00
parent faa8a72a86
commit 2f00d76ecc
3 changed files with 192 additions and 17 deletions
+94
View File
@@ -257,6 +257,100 @@ def _corner_grid() -> Grid:
return Grid.from_spec(spec)
def _corridor_grid() -> Grid:
"""A one-cell corridor along row 0 (rest walls) — no way around an ally."""
rows = ["." * 8] + ["#" * 8] * 7
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
spec = MapSpec(name="corridor", terrain=tuple(rows), legend=legend)
return Grid.from_spec(spec)
def _hero_ally_foe(
policy: PolicyFn, rng: list[int], *, ally_pos: tuple[int, int], foe_pos: tuple[int, int],
) -> CombatEngine:
"""hero+ally (players) and foe (monsters) on the corridor; hero acts first."""
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
ally = make_state(_combatant("ally"), "players", ally_pos)
foe = make_state(_combatant("foe"), "monsters", foe_pos)
return CombatEngine(
ScriptedRng(rng), _corridor_grid(), [hero, ally, foe],
policy=policy, round_cap=1,
)
def test_move_passes_through_friendly_square() -> None:
# SRD Movement: "You can move through a square occupied by a friendly
# character, unless you are charging." Ally at (0, 1) is the only route
# to the foe at (0, 3) — the move crosses it and ends free at (0, 2).
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", target_id="foe"),)
return _WAIT
eng = _hero_ally_foe(policy, [10, 5, 5], ally_pos=(0, 1), foe_pos=(0, 3))
t = eng.run().transcript
assert "round 1 hero: move (0,0)->(0,1)->(0,2)" in t
assert eng._states[0].pos == (0, 2)
def test_move_cannot_end_on_friendly_square() -> None:
# Ally at (0, 2) directly in front of the foe: the walk crosses it but
# must not END there — the path is trimmed to the last free cell (0, 1).
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", target_id="foe"),)
return _WAIT
eng = _hero_ally_foe(policy, [10, 5, 5], ally_pos=(0, 2), foe_pos=(0, 3))
t = eng.run().transcript
assert "round 1 hero: move (0,0)->(0,1)" in t
assert eng._states[0].pos == (0, 1)
def test_run_passes_through_friendly_square() -> None:
# Run reuses the move machinery, so allies are passable there too.
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", target_id="foe"),)
return _WAIT
eng = _hero_ally_foe(policy, [10, 5, 5], ally_pos=(0, 1), foe_pos=(0, 4))
t = eng.run().transcript
assert eng._states[0].pos == (0, 3)
assert "round 1 hero: run" in t
def test_move_to_destination_behind_friendly_square() -> None:
# Explicit destination (0, 2) past the ally at (0, 1): reachable now that
# the cost field lets the path cross friendly squares.
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", destination=(0, 2)),)
return _WAIT
eng = _hero_ally_foe(policy, [10, 5, 5], ally_pos=(0, 1), foe_pos=(0, 7))
t = eng.run().transcript
assert "round 1 hero: move -> (0,2)" in t
assert eng._states[0].pos == (0, 2)
def test_charge_still_blocked_by_friendly_square() -> None:
# The charge exception: "unless you are charging" — the ally at (0, 1)
# on the straight line keeps the charge impossible; the hero stays put.
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="charge", target_id="foe"),)
return _WAIT
eng = _hero_ally_foe(policy, [10, 5, 5], ally_pos=(0, 1), foe_pos=(0, 3))
t = eng.run().transcript
assert eng._states[0].pos == (0, 0)
assert not any("hero: charge" in line for line in t)
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.