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
+14 -5
View File
@@ -584,11 +584,20 @@ Règles modélisées :
allonge naturelle`, la dead zone vaut donc 5 ft pour un porteur
Small/Medium et 10 ft pour un porteur Large. Une arme ordinaire sans
allonge menace les cases adjacentes (disque de 5 ft).
Mouvement : un déplacement parcourt jusqu'à la
vitesse du combattant en cases le long du plus court chemin réel (champ de
coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à
l'ennemi le plus proche — les combattants contournent les murs au lieu
d'osciller contre eux.
Mouvement : un déplacement parcourt jusqu'à la
vitesse du combattant en cases le long du plus court chemin réel (champ de
coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à
l'ennemi le plus proche — les combattants contournent les murs au lieu
d'osciller contre eux. Conformance L5 (SRD « Movement, Position and
Distance ») : « You can move through a square occupied by a friendly
character, unless you are charging » — `move` et `run` **traversent** les
cases des alliés (les ennemis bloquent toujours le chemin), mais ne
peuvent jamais **finir** sur une case occupée : entrer sur une case alliée
exige un prolongement libre (règle du « pass-through »), et la queue du
chemin est tronquée si le budget s'épuise sur une case alliée. La charge
reste bloquée par les créatures (ligne droite dégagée), comme la retraite
(limite documentée : le passage à travers les alliés n'est pas modélisé
pour `withdraw` et le pas de placement auto-dirigé).
Vitesse de déplacement (V4) : le budget de tout mouvement (déplacement,
charge 2×, retraite 2×, course 4×) est le **meilleur entre la vitesse au
sol (`speed_land_ft`) et la vitesse de vol (`speed_fly_ft`)** — une créature
+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)
+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.