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". # square and never share. The rank ceiling for "takes up less than 1 square".
_TINY_OR_SMALLER_MAX_RANK: int = _size_rank("Tiny") _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: def _can_cooccupy(size_a: str, size_b: str) -> bool:
"""True when two sizes may share a single grid square (PF1e co-occupancy). """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 False
return min(rank_a, rank_b) <= _TINY_OR_SMALLER_MAX_RANK 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). # Number of grid cells per side of a creature's footprint (square footprint).
# Unknown sizes fall back to 1 (the historical single-cell behavior). # Unknown sizes fall back to 1 (the historical single-cell behavior).
SIZE_CELLS: dict[str, int] = { SIZE_CELLS: dict[str, int] = {
@@ -2336,14 +2356,16 @@ class CombatEngine:
return frozenset(blocked) return frozenset(blocked)
def _blocked_hostile(self, mover: CombatantState) -> frozenset[Pos]: 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 A hostile creature's square blocks movement (SRD "Movement" /
Distance) — "You can move through a square occupied by a friendly "Moving Through a Square"), except when the mover may co-occupy it
character, unless you are charging." Co-occupiable creatures (a (``_can_cooccupy``, SRD "Big Creatures and Smaller Creatures") or may
Tiny-or-smaller mover and a strictly larger occupant, or the reverse) pass through it (``_can_pass_through``, SRD "Moving Through a Square" :
are excluded too — the mover may pass through and end on their squares "You can move through a square occupied by a creature three size
(SRD "Big and Little Creatures in Combat"). 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( return frozenset(
cell cell
@@ -2352,6 +2374,7 @@ class CombatEngine:
and s is not mover and s is not mover
and s.side != mover.side and s.side != mover.side
and not _can_cooccupy(mover.combatant.size, s.combatant.size) 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() for cell in s.occupied_cells()
) )
@@ -3511,6 +3534,42 @@ class CombatEngine:
return True return True
return False 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: def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
"""Check for ranged-AoO: firing a ranged-style weapon in a threatened square provokes. """Check for ranged-AoO: firing a ranged-style weapon in a threatened square provokes.
@@ -7056,6 +7115,14 @@ class CombatEngine:
break break
if not state.active: if not state.active:
break 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 state.pos = step
actual_path.append(step) actual_path.append(step)
self._check_reactions(ReactionEvent(kind="movement_step", actor=state)) 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 corner the way a Dijkstra ``move`` does. The path is the Bresenham
line from the combatant's anchor toward ``goal``, walked under the 4x line from the combatant's anchor toward ``goal``, walked under the 4x
speed budget and stopped at the first obstacle, mirroring the charge's speed budget and stopped at the first obstacle, mirroring the charge's
straight-line clear-path logic (CRB "Charge"): impassable terrain, a straight-line clear-path logic (CRB "Charge"): impassable terrain, a
difficult-terrain cell (CRB "Difficult Terrain": "You can't run or difficult-terrain cell (CRB "Difficult Terrain": "You can't run or
charge across difficult terrain"), or a blocking creature. A hostile charge across difficult terrain"), or a blocking creature. A hostile
creature (including the target itself) halts the walk one cell short, creature (including the target itself) halts the walk one cell short,
so an auto-directed run ends adjacent to the target. Friendly squares 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 — are still crossed (SRD Movement, conformance L5) but never ended on —
the tail is trimmed like ``_descend_path``. the tail is trimmed like ``_descend_path``.
""" """
@@ -7230,15 +7300,31 @@ class CombatEngine:
to_target: dict[Pos, int], to_target: dict[Pos, int],
pos_cost: int, pos_cost: int,
) -> bool: ) -> bool:
"""True if some adjacent anchor is fully free and descends the field.""" """True if a fully free anchor that descends the field is reachable from ``pos``.
row, col = pos
for d_row, d_col in _STEP_DELTAS: The free anchor may sit beyond a CHAIN of occupied anchors: crossing a
footprint = self._footprint_at((row + d_row, col + d_col), state) creature three size categories away (SRD "Moving Through a Square")
if not self._cells_free(footprint, state): walks through several non-free cells before emerging on the far side,
continue so a one-step lookahead is not enough. The chain must strictly
cost = self._footprint_cost(footprint, to_target) descend the cost field at every hop, so the walk never dead-ends on
if cost is not None and cost < pos_cost: an occupied cell (movement may not END there).
return True """
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 return False
def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool: def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool:
+261
View File
@@ -0,0 +1,261 @@
"""Moving THROUGH a hostile square and the entry AoO (Task 7 conformance).
SRD "Moving Through a Square" (Core Rulebook): "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." and
"You can't end your movement in the same square as another creature unless it
is helpless." Two creatures whose size ranks differ by three or more may
therefore CROSS each other's squares (through-gate, ``_blocked_hostile``) but
never END on one (end-gate, ``_blocked``/``_cells_free``); the crossing
provokes exactly one AoO from the occupant per round, capped by the per-round
allowance. Co-occupiable pairs (Tiny-or-smaller vs strictly larger, S4c) keep
their no-AoO sharing behaviour, and a 0-2 category difference still blocks
completely ("You can't move through a square occupied by an opponent unless
the opponent is helpless").
"""
from __future__ import annotations
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import (
Action,
CombatantState,
CombatEngine,
_can_pass_through,
)
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.rng import ScriptedRng
from tests.test_creature_sizes import make_combatant, make_engine, make_grid, make_state
_COMBAT_REFLEXES = AbilitySpec(
name="Combat Reflexes", category="feat", source_detail="feat lv1"
)
def _corridor_grid(cols: int = 10) -> Grid:
"""3-row corridor: floor row 1, wall rows 0 and 2 (forces straight walks)."""
terrain = ("#" * cols, "." * cols, "#" * cols)
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
return Grid.from_spec(MapSpec(name="corridor", terrain=terrain, legend=legend))
def _huge_guard(*, reflexes: bool = False) -> CombatantState:
"""Huge guard anchored at (0, 4): its 3x3 footprint fills the corridor."""
huge = make_combatant("huge", size="Huge", hp=20, ac=15, attack_bonus=5, speed=0)
if reflexes:
huge = huge.model_copy(
update={
"features": [_COMBAT_REFLEXES],
"abilities": huge.abilities.model_copy(update={"dex_score": 14}),
}
)
return make_state(huge, "b", (0, 4))
class TestPassThroughGate:
"""A 3+ category size difference makes the hostile square crossable, not endable."""
def test_three_categories_apart_is_passable(self) -> None:
"""Given: Small vs Huge (3 categories apart)
When: checking the pass-through predicate
Then: the pair may cross each other's squares."""
assert _can_pass_through("Small", "Huge") is True
assert _can_pass_through("Huge", "Small") is True
def test_two_or_fewer_categories_still_blocks(self) -> None:
"""Given: Medium vs Huge (2 apart) and Medium vs Medium (same)
When: checking the pass-through predicate
Then: neither pair may cross - the square blocks completely."""
assert _can_pass_through("Medium", "Huge") is False
assert _can_pass_through("Medium", "Medium") is False
def test_small_may_cross_a_huge_hostile_cell(self) -> None:
"""Given: a Small mover and a hostile Huge creature
When: checking the through-gate (_cells_walkable)
Then: the Huge creature's cells are walkable for the Small mover."""
small = make_combatant("small", size="Small", speed=30)
s_small = make_state(small, "a", (1, 1))
s_huge = _huge_guard()
eng = make_engine([s_small, s_huge])
assert eng._cells_walkable(s_huge.occupied_cells(), s_small) is True
assert eng._blocked_hostile(s_small) == frozenset()
def test_small_cannot_end_on_a_huge_hostile_cell(self) -> None:
"""Given: a Small mover and a hostile Huge creature
When: checking the end-gate (_cells_free / destination)
Then: ending on the Huge creature's square is forbidden."""
small = make_combatant("small", size="Small", speed=30)
s_small = make_state(small, "a", (1, 1))
s_huge = _huge_guard()
eng = make_engine([s_small, s_huge])
assert eng._cells_free(s_huge.occupied_cells(), s_small) is False
assert eng._destination_reachable(s_small, (1, 4), friendly_passable=True) is False
def test_two_category_enemy_blocks_completely(self) -> None:
"""Given: a Medium mover and a hostile Huge creature (2 categories)
When: checking both gates
Then: the square is neither crossable nor endable."""
med = make_combatant("med", size="Medium", speed=30)
s_med = make_state(med, "a", (1, 1))
s_huge = _huge_guard()
eng = make_engine([s_med, s_huge])
assert eng._cells_walkable(s_huge.occupied_cells(), s_med) is False
assert eng._cells_free(s_huge.occupied_cells(), s_med) is False
class TestEnterAoo:
"""Entering a pass-through hostile square provokes exactly one AoO."""
def _pair(
self, rng: list[int]
) -> tuple[CombatEngine, CombatantState, CombatantState]:
small = make_combatant("small", size="Small", hp=20, ac=12)
s_small = make_state(small, "a", (1, 3))
s_huge = _huge_guard()
eng = CombatEngine(ScriptedRng(rng), make_grid(), [s_small, s_huge])
return eng, s_small, s_huge
def test_entering_a_hostile_square_provokes_the_occupant(self) -> None:
"""Given: a Small mover one step from a Huge hostile's square
When: _check_enter_aoo resolves the step INTO that square
Then: the occupant makes one AoO and the budget records it."""
eng, s_small, s_huge = self._pair([1])
assert eng._check_enter_aoo(s_small, (1, 4)) is False
assert eng._aoo_used.get("huge") == 1
assert any("AoO vs small" in line for line in eng._transcript)
def test_second_entry_step_from_inside_does_not_reprovoke(self) -> None:
"""Given: a Small mover already overlapping the Huge creature's cells
When: _check_enter_aoo resolves a further step inside the same creature
Then: no fresh entry, so no second AoO (and no die consumed)."""
eng, s_small, s_huge = self._pair([])
s_small.pos = (1, 4)
assert eng._check_enter_aoo(s_small, (1, 5)) is False
assert "huge" not in eng._aoo_used
def test_entry_aoo_respects_the_per_round_allowance(self) -> None:
"""Given: a Huge occupant that already spent its only AoO
When: the Small mover enters its square
Then: no AoO is rolled (empty scripted RNG would raise)."""
eng, s_small, s_huge = self._pair([])
eng._aoo_used["huge"] = 1
assert eng._check_enter_aoo(s_small, (1, 4)) is False
def test_entering_a_free_square_does_not_provoke(self) -> None:
"""Given: a Small mover and a Huge hostile two cells away
When: _check_enter_aoo resolves a step onto an empty square
Then: no AoO (empty scripted RNG would raise)."""
eng, s_small, s_huge = self._pair([])
assert eng._check_enter_aoo(s_small, (1, 2)) is False
def test_tiny_cooccupancy_entry_never_provokes(self) -> None:
"""Given: a Tiny mover and a Medium hostile (S4c co-occupiable pair)
When: _check_enter_aoo resolves a step INTO the hostile's square
Then: sharing a square provokes no AoO (S4c behaviour preserved)."""
tiny = make_combatant("tiny", size="Tiny", reach_ft=0, hp=20, ac=12)
foe = make_combatant("foe", size="Medium", hp=20, ac=15, attack_bonus=5, speed=0)
s_tiny = make_state(tiny, "a", (3, 3))
s_foe = make_state(foe, "b", (3, 4))
eng = make_engine([s_tiny, s_foe])
assert eng._check_enter_aoo(s_tiny, (3, 4)) is False
assert "foe" not in eng._aoo_used
def test_entry_aoo_that_drops_the_mover_stops_the_walk(self) -> None:
"""Given: a 3 hp Small mover entering a Huge hostile's square
When: the entry AoO hits for 4 damage
Then: _check_enter_aoo reports the drop (the caller stops the walk)."""
eng, s_small, s_huge = self._pair([15, 4])
s_small.hp = 3
assert eng._check_enter_aoo(s_small, (1, 4)) is True
assert not s_small.active
class TestPassThroughWalkIntegration:
"""The step-by-step walk crosses a Huge creature and the occupant strikes."""
def test_move_walks_through_a_huge_creature_and_ends_beyond(self) -> None:
"""Given: a Small runner facing a Huge guard (Combat Reflexes, 3 AoOs)
When: the runner moves straight through the guard's corridor square
Then: the runner ends beyond the guard and the guard makes exactly two
AoOs (leaving a threatened square + entering its square), never more."""
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "small":
return (Action(kind="move", destination=(1, 7)),)
return (Action(kind="wait"),)
small = make_combatant("small", size="Small", hp=20, ac=12, speed=30)
s_small = make_state(small, "a", (1, 1))
s_huge = _huge_guard(reflexes=True)
engine = CombatEngine(
ScriptedRng([19, 5, 1, 1]),
_corridor_grid(),
[s_small, s_huge],
round_cap=1,
policy=policy,
)
result = engine.run()
assert s_small.pos == (1, 7)
assert s_small.occupied_cells() & s_huge.occupied_cells() == frozenset()
aoo_lines = [line for line in result.transcript if "AoO vs small" in line]
assert len(aoo_lines) == 2
def test_move_cannot_end_on_the_huge_creature(self) -> None:
"""Given: the same Small runner and Huge guard
When: the runner's explicit destination is a cell of the guard's square
Then: the move is rejected (unreachable_destination) and the runner stays."""
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "small":
return (Action(kind="move", destination=(1, 5)),)
return (Action(kind="wait"),)
small = make_combatant("small", size="Small", hp=20, ac=12, speed=30)
s_small = make_state(small, "a", (1, 1))
s_huge = _huge_guard()
engine = CombatEngine(
ScriptedRng([19, 5]),
_corridor_grid(),
[s_small, s_huge],
round_cap=1,
policy=policy,
)
result = engine.run()
assert s_small.pos == (1, 1)
assert any(
"move rejected (unreachable_destination)" in line
for line in result.transcript
)
def test_medium_cannot_walk_through_the_huge_creature(self) -> None:
"""Given: a Medium runner (only 2 categories below the Huge guard)
When: it tries to move to the corridor cell beyond the guard
Then: the guard blocks the only route and the move is rejected."""
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "med":
return (Action(kind="move", destination=(1, 7)),)
return (Action(kind="wait"),)
med = make_combatant("med", size="Medium", hp=20, ac=12, speed=30)
s_med = make_state(med, "a", (1, 1))
s_huge = _huge_guard()
engine = CombatEngine(
ScriptedRng([19, 5]),
_corridor_grid(),
[s_med, s_huge],
round_cap=1,
policy=policy,
)
result = engine.run()
assert s_med.pos == (1, 1)
assert any(
"move rejected (unreachable_destination)" in line
for line in result.transcript
)