diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 7ae3e16..f4578a1 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/tests/test_pass_through_aoo.py b/tests/test_pass_through_aoo.py new file mode 100644 index 0000000..2037f5d --- /dev/null +++ b/tests/test_pass_through_aoo.py @@ -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 + )