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
+84 -12
View File
@@ -1953,6 +1953,20 @@ class CombatEngine:
for cell in s.occupied_cells()
)
def _blocked_hostile(self, mover: CombatantState) -> frozenset[Pos]:
"""Occupied cells of active creatures hostile to ``mover``.
Same-side allies are excluded: PF1e (SRD Movement, Position and
Distance) — "You can move through a square occupied by a friendly
character, unless you are charging."
"""
return frozenset(
cell
for s in self._states
if s.active and s is not mover and s.side != mover.side
for cell in s.occupied_cells()
)
def creature_distance(self, a: CombatantState, b: CombatantState) -> int:
"""Minimum grid distance between any occupied cell of ``a`` and ``b``."""
cells_b = b.occupied_cells()
@@ -1973,6 +1987,18 @@ class CombatEngine:
self._grid.passable(cell) and cell not in blocked for cell in cells
)
def _cells_walkable(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
"""True if a move/run path may enter these cells (allies allowed).
Inbounds, passable, and not occupied by a hostile creature; a
friendly creature's square may be crossed through (SRD Movement),
though the movement may not END there (``_cells_free`` gates that).
"""
hostile = self._blocked_hostile(mover)
return all(
self._grid.passable(cell) and cell not in hostile for cell in cells
)
def _footprint_at(self, anchor: Pos, state: CombatantState) -> frozenset[Pos]:
"""The cells ``state`` would occupy if its anchor were at ``anchor``."""
n = SIZE_CELLS.get(state.combatant.size, 1)
@@ -3605,7 +3631,8 @@ class CombatEngine:
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if action.destination is not None:
reachable = self._destination_reachable(
state, action.destination, speed_factor=_RUN_SPEED_FACTOR
state, action.destination, speed_factor=_RUN_SPEED_FACTOR,
friendly_passable=True,
)
if not reachable:
self._log(
@@ -3620,12 +3647,14 @@ class CombatEngine:
action.destination,
speed_factor=_RUN_SPEED_FACTOR,
avoid_difficult=True,
friendly_passable=True,
)
self._walk_steps(state, path)
state.moved_this_turn = True
elif target is not None:
path = self._descend_path(
state, target.pos, speed_factor=_RUN_SPEED_FACTOR, avoid_difficult=True
state, target.pos, speed_factor=_RUN_SPEED_FACTOR, avoid_difficult=True,
friendly_passable=True,
)
if self._walk_steps(state, path):
state.moved_this_turn = True
@@ -5808,7 +5837,7 @@ class CombatEngine:
if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return
path = self._move_path(state, target)
path = self._move_path(state, target, friendly_passable=True)
start = state.pos
actual_path = self._walk_steps(state, path)
if actual_path:
@@ -5829,7 +5858,7 @@ class CombatEngine:
if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if not self._destination_reachable(state, destination):
if not self._destination_reachable(state, destination, friendly_passable=True):
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
@@ -5837,7 +5866,7 @@ class CombatEngine:
return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
)
path = self._descend_path(state, destination)
path = self._descend_path(state, destination, friendly_passable=True)
self._walk_steps(state, path)
state.moved_this_turn = True
self._log(
@@ -5852,10 +5881,14 @@ class CombatEngine:
destination: Pos,
*,
speed_factor: int = 1,
friendly_passable: bool = False,
) -> bool:
"""True when destination is passable, unoccupied and within this speed.
``speed_factor`` widens the budget (run: 4x speed, U5e).
``friendly_passable`` routes the cost field around allies' squares
(move/run may cross them, SRD Movement); the destination itself must
still be completely free.
"""
speed_cells = int(
_speed_ft(state)
@@ -5867,7 +5900,8 @@ class CombatEngine:
return destination == state.pos
if not self._cells_free(self._footprint_at(destination, state), state):
return False
field = self._grid.reachable(state.pos, speed_cells, self._blocked(state))
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
field = self._grid.reachable(state.pos, speed_cells, blocked)
return destination in field
def _walk_steps(
@@ -5887,13 +5921,15 @@ class CombatEngine:
break
return actual_path
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
def _move_path(
self, state: CombatantState, target: CombatantState, *, friendly_passable: bool = False
) -> list[Pos]:
"""Full movement path toward the target, up to the combatant's speed.
Greedy walk of the anchor over the Dijkstra cost field from the target,
requiring the whole footprint to fit at each step.
"""
return self._descend_path(state, target.pos)
return self._descend_path(state, target.pos, friendly_passable=friendly_passable)
def _descend_path(
self,
@@ -5902,13 +5938,16 @@ class CombatEngine:
*,
speed_factor: int = 1,
avoid_difficult: bool = False,
friendly_passable: bool = False,
) -> list[Pos]:
"""Greedy walk of the anchor over the cost field from ``anchor``.
``speed_factor`` widens the movement budget (run: 4x speed, U5e).
``avoid_difficult`` excludes difficult-terrain cells from the walk
(PF1e: a run cannot cross difficult terrain), so the walk stops in
front of the obstacle.
front of the obstacle. ``friendly_passable`` lets the walk CROSS
same-side creatures' squares (move/run, PF1e SRD Movement) while the
tail is trimmed so the movement never ENDS on an occupied cell.
"""
speed_mult = self._get_speed_mult(state)
speed_cells = int(
@@ -5919,7 +5958,7 @@ class CombatEngine:
)
if speed_cells <= 0:
return []
blocked = self._blocked(state)
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
to_target = self._grid.reachable(anchor, None, blocked)
if self._footprint_cost(state.occupied_cells(), to_target) is None:
return []
@@ -5928,7 +5967,8 @@ class CombatEngine:
budget = speed_cells
while budget > 0:
best = self._best_move_step(
state, current, to_target, budget, avoid_difficult=avoid_difficult
state, current, to_target, budget,
avoid_difficult=avoid_difficult, friendly_passable=friendly_passable,
)
if best is None:
break
@@ -5936,6 +5976,9 @@ class CombatEngine:
current = nxt
path.append(current)
budget -= step_cost
if friendly_passable:
while path and not self._cells_free(self._footprint_at(path[-1], state), state):
path.pop()
return path
def _best_move_step(
@@ -5946,6 +5989,7 @@ class CombatEngine:
budget: int,
*,
avoid_difficult: bool = False,
friendly_passable: bool = False,
) -> tuple[int, int, Pos] | None:
"""Pick the adjacent anchor that best descends the cost field toward the target."""
current_cost = self._footprint_cost(self._footprint_at(current, state), to_target)
@@ -5953,16 +5997,26 @@ class CombatEngine:
return None
best: tuple[int, int, Pos] | None = None
row, col = current
fits = self._cells_walkable if friendly_passable else self._cells_free
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
footprint = self._footprint_at(nxt, state)
if not self._cells_free(footprint, state):
if not fits(footprint, state):
continue
if avoid_difficult and self._footprint_touches_difficult(footprint):
continue
nxt_cost = self._footprint_cost(footprint, to_target)
if nxt_cost is None or nxt_cost >= current_cost:
continue
# Friendly square: enter it only to cross it — a free cell must
# descend from it, so the walk never dead-ends on an ally
# (movement may not END on an occupied cell).
if (
friendly_passable
and not self._cells_free(footprint, state)
and not self._has_free_descent(state, nxt, to_target, nxt_cost)
):
continue
step = self._grid.step_cost(current, nxt, 0)
if step > budget:
continue
@@ -5971,6 +6025,24 @@ class CombatEngine:
best = (score, step, nxt)
return best
def _has_free_descent(
self,
state: CombatantState,
pos: Pos,
to_target: dict[Pos, int],
pos_cost: int,
) -> bool:
"""True if some adjacent anchor is fully free and descends the field."""
row, col = pos
for d_row, d_col in _STEP_DELTAS:
footprint = self._footprint_at((row + d_row, col + d_col), state)
if not self._cells_free(footprint, state):
continue
cost = self._footprint_cost(footprint, to_target)
if cost is not None and cost < pos_cost:
return True
return False
def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool:
"""True if any footprint cell is difficult terrain (move cost > 1)."""
return any(self._is_difficult_cell(cell) for cell in footprint)