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