From 2f00d76eccf4ce9d08096d455aff61998c1a9858 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 12 Sep 2026 09:18:29 +0200 Subject: [PATCH] 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 --- README.md | 19 +++++-- src/pf1e_simulator/combat.py | 96 +++++++++++++++++++++++++++++----- tests/test_move_destination.py | 94 +++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a85711c..97d1b0a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 824de3a..922ce23 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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) diff --git a/tests/test_move_destination.py b/tests/test_move_destination.py index fe68834..4ba5643 100644 --- a/tests/test_move_destination.py +++ b/tests/test_move_destination.py @@ -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.