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