fix(rules): pass through 3+ size categories apart, entry provokes AoO

SRD Moving Through a Square: 'Any creature can move through a square
occupied by a creature three size categories larger than itself. A big
creature can move through a square occupied by a creature three size
categories smaller than it is. Creatures moving through squares occupied
by other creatures provoke attacks of opportunity from those creatures.'

- _can_pass_through: size ranks differ by >= 3 opens the through-gate
  (_blocked_hostile) while _blocked/_cells_free keep the end-gate
  ('You can't end your movement in the same square as another creature
  unless it is helpless').
- _check_enter_aoo wired into _walk_steps (move, explicit-destination
  move, and run all commit steps there): the occupant strikes once per
  fresh entry, capped by the per-round allowance; a dropped mover stops
  the walk. S4c co-occupiable pairs (Tiny-or-smaller) keep no-AoO sharing.
- _has_free_descent generalized to a descending BFS so a walk can cross
  a multi-cell Huge+ creature (one-step lookahead dead-ended inside).
- Golden transcript untouched (all 1480 tests green).
This commit is contained in:
2026-09-16 15:10:21 +02:00
parent 437507ee2c
commit 9a16ca0388
2 changed files with 368 additions and 21 deletions
+107 -21
View File
@@ -333,6 +333,11 @@ _SIZE_NATURAL_REACH_FT: dict[str, int] = {
# square and never share. The rank ceiling for "takes up less than 1 square".
_TINY_OR_SMALLER_MAX_RANK: int = _size_rank("Tiny")
# SRD "Moving Through a Square": "Any creature can move through a square
# occupied by a creature three size categories larger than itself." The
# minimum size-rank gap that opens the pass-through gate.
_PASS_THROUGH_MIN_SIZE_GAP: int = 3
def _can_cooccupy(size_a: str, size_b: str) -> bool:
"""True when two sizes may share a single grid square (PF1e co-occupancy).
@@ -346,6 +351,21 @@ def _can_cooccupy(size_a: str, size_b: str) -> bool:
return False
return min(rank_a, rank_b) <= _TINY_OR_SMALLER_MAX_RANK
def _can_pass_through(size_a: str, size_b: str) -> bool:
"""True when a creature of ``size_a`` may move THROUGH a square occupied by ``size_b``.
PF1e (SRD "Movement, Position, and Distance" / "Moving Through a Square"):
"You can move through a square occupied by a creature three size categories
larger than you" and "A big creature can move through a square occupied by
a creature three size categories smaller than it" — the size ranks must
differ by at least three categories. The pass-through may cross the
occupied square but never END on it (``_blocked`` keeps the
co-occupancy-only end gate; SRD: "You can't end your movement in the same
square as another creature unless it is helpless").
"""
return abs(_size_rank(size_a) - _size_rank(size_b)) >= _PASS_THROUGH_MIN_SIZE_GAP
# Number of grid cells per side of a creature's footprint (square footprint).
# Unknown sizes fall back to 1 (the historical single-cell behavior).
SIZE_CELLS: dict[str, int] = {
@@ -2336,14 +2356,16 @@ class CombatEngine:
return frozenset(blocked)
def _blocked_hostile(self, mover: CombatantState) -> frozenset[Pos]:
"""Occupied cells of active creatures hostile to ``mover``.
"""Hostile-occupied cells that block the mover's path.
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." Co-occupiable creatures (a
Tiny-or-smaller mover and a strictly larger occupant, or the reverse)
are excluded too — the mover may pass through and end on their squares
(SRD "Big and Little Creatures in Combat").
A hostile creature's square blocks movement (SRD "Movement" /
"Moving Through a Square"), except when the mover may co-occupy it
(``_can_cooccupy``, SRD "Big Creatures and Smaller Creatures") or may
pass through it (``_can_pass_through``, SRD "Moving Through a Square" :
"You can move through a square occupied by a creature three size
categories larger than you"). The distinction between ``_blocked_hostile``
(through-gate) and ``_blocked`` (end-gate) ensures pass-through
candidates are walkable but not valid endpoints.
"""
return frozenset(
cell
@@ -2352,6 +2374,7 @@ class CombatEngine:
and s is not mover
and s.side != mover.side
and not _can_cooccupy(mover.combatant.size, s.combatant.size)
and not _can_pass_through(mover.combatant.size, s.combatant.size)
for cell in s.occupied_cells()
)
@@ -3511,6 +3534,42 @@ class CombatEngine:
return True
return False
def _check_enter_aoo(self, mover: CombatantState, next_pos: Pos) -> bool:
"""Resolve AoOs provoked by ENTERING a hostile creature's square.
PF1e (SRD "Movement, Position, and Distance" / "Moving Through a
Square"): "You can move through a square occupied by a creature three
size categories larger than you" / "a creature three size categories
smaller than it" — the pass-through provokes an attack of opportunity
from the occupant. The strike fires when the mover's footprint at
``next_pos`` overlaps a hostile occupant's cells but the current
footprint does not (a fresh entry, not an existing co-occupancy).
Co-occupiable pairs (Tiny-or-smaller vs strictly larger, S4c) keep
their no-AoO sharing behaviour. Capped by the per-round AoO
allowance; no per-opponent dedupe (``_move_aoo_provoked`` dedupes
only leaving threatened cells). Returns True if the mover dropped.
"""
before = mover.occupied_cells()
after = self._footprint_at(next_pos, mover)
for s in self._states:
if not s.active or s is mover or s.side == mover.side:
continue
if not _can_pass_through(mover.combatant.size, s.combatant.size):
continue
if _can_cooccupy(mover.combatant.size, s.combatant.size):
continue # S4c: sharing a square never provokes
cells = s.occupied_cells()
if not (after & cells):
continue
if before & cells:
continue # already co-occupying: no fresh entry
if self._aoo_used.get(s.combatant.id, 0) >= self._aoo_allowance(s):
continue
self._resolve_aoo(s, mover)
if not mover.active:
return True
return False
def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
"""Check for ranged-AoO: firing a ranged-style weapon in a threatened square provokes.
@@ -7056,6 +7115,14 @@ class CombatEngine:
break
if not state.active:
break
# SRD "Moving Through a Square": "Creatures moving through squares
# occupied by other creatures provoke attacks of opportunity from
# those creatures." — resolved BEFORE the step is committed, so a
# mover dropped by the entry AoO never lands on the square.
if self._check_enter_aoo(state, step):
break
if not state.active:
break
state.pos = step
actual_path.append(step)
self._check_reactions(ReactionEvent(kind="movement_step", actor=state))
@@ -7137,11 +7204,14 @@ class CombatEngine:
corner the way a Dijkstra ``move`` does. The path is the Bresenham
line from the combatant's anchor toward ``goal``, walked under the 4x
speed budget and stopped at the first obstacle, mirroring the charge's
straight-line clear-path logic (CRB "Charge"): impassable terrain, a
difficult-terrain cell (CRB "Difficult Terrain": "You can't run or
charge across difficult terrain"), or a blocking creature. A hostile
creature (including the target itself) halts the walk one cell short,
so an auto-directed run ends adjacent to the target. Friendly squares
straight-line clear-path logic (CRB "Charge"): impassable terrain, a
difficult-terrain cell (CRB "Difficult Terrain": "You can't run or
charge across difficult terrain"), or a blocking creature. A hostile
creature (including the target itself) halts the walk one cell short,
so an auto-directed run ends adjacent to the target — except one the
mover may pass through (three size categories apart, SRD "Moving
Through a Square"), which is crossed with an entry AoO and never
ended on. Friendly squares
are still crossed (SRD Movement, conformance L5) but never ended on —
the tail is trimmed like ``_descend_path``.
"""
@@ -7230,15 +7300,31 @@ class CombatEngine:
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
"""True if a fully free anchor that descends the field is reachable from ``pos``.
The free anchor may sit beyond a CHAIN of occupied anchors: crossing a
creature three size categories away (SRD "Moving Through a Square")
walks through several non-free cells before emerging on the far side,
so a one-step lookahead is not enough. The chain must strictly
descend the cost field at every hop, so the walk never dead-ends on
an occupied cell (movement may not END there).
"""
seen: set[Pos] = {pos}
frontier: list[tuple[Pos, int]] = [(pos, pos_cost)]
while frontier:
current, current_cost = frontier.pop()
row, col = current
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
footprint = self._footprint_at(nxt, state)
cost = self._footprint_cost(footprint, to_target)
if cost is None or cost >= current_cost:
continue
if self._cells_free(footprint, state):
return True
if nxt not in seen:
seen.add(nxt)
frontier.append((nxt, cost))
return False
def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool: