Files
pf1e-simulator/tests/test_pass_through_aoo.py
T
ctan 9a16ca0388 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).
2026-09-16 15:10:21 +02:00

262 lines
12 KiB
Python

"""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
)