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:
+107
-21
@@ -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:
|
||||||
|
|||||||
@@ -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