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:
+107
-21
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user