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