fix(rules): require adjacency or pull the target when grappling
SRD 'Grapple': 'If you successfully grapple a creature that is not adjacent to you, move that creature to an adjacent open space (if no space is available, your grapple fails).' _maneuver_grapple now pulls a non-adjacent target to the nearest free anchor whose footprint is adjacent to the grappler (5-10-5 distance, (row,col) tie-break, new _nearest_anchor_adjacent_to helper) and fails outright when no adjacent open space exists. Three tests: pull, no-space failure, adjacent no-move. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -614,3 +614,60 @@ def test_cmd_flat_footed_drops_dex_bonus_keeps_penalty() -> None:
|
||||
)
|
||||
)
|
||||
assert engine._maneuver_cmd(clumsy_flat) == 10 + 2 - 2 # penalty kept
|
||||
|
||||
|
||||
# --- L8: grapple requires adjacency / pulls the target -----------------------
|
||||
|
||||
|
||||
def test_grapple_from_range_pulls_target_adjacent() -> None:
|
||||
# SRD "Grapple": "If you successfully grapple a creature that is not
|
||||
# adjacent to you, move that creature to an adjacent open space".
|
||||
# Given: a ranged-successful grappler (CMB 20 vs CMD 12) and a target
|
||||
# two squares away (no provoked AoO — it does not threaten)
|
||||
# When: the grapple succeeds
|
||||
# Then: the target is pulled to the nearest open adjacent cell and both
|
||||
# gain the grappled condition.
|
||||
a = make_state(make_combatant("a", attack_bonus=20), "players", (1, 1))
|
||||
b = make_state(make_combatant("b"), "monsters", (1, 3))
|
||||
engine = make_engine([5], [a, b])
|
||||
engine._execute_maneuver(a, b, "grapple")
|
||||
assert b.pos == (0, 2) # nearest open adjacent cell (row,col tie-break)
|
||||
assert b.grappled_with == "a"
|
||||
assert a.grappled_with == "b"
|
||||
assert any("grapple pulls b (1,3)->(0,2)" in line for line in engine._transcript)
|
||||
assert any("SUCCESS (grappled)" in line for line in engine._transcript)
|
||||
|
||||
|
||||
def test_grapple_from_range_fails_without_open_adjacent_space() -> None:
|
||||
# SRD "Grapple": "(if no space is available, your grapple fails)".
|
||||
# Given: grappler in the grid corner, every adjacent cell occupied
|
||||
# When: the CMB check succeeds anyway
|
||||
# Then: the target is NOT pulled, NOT grappled, and the attempt is logged
|
||||
# as failed for lack of space.
|
||||
a = make_state(make_combatant("a", attack_bonus=20), "players", (0, 0))
|
||||
b = make_state(make_combatant("b"), "monsters", (0, 3))
|
||||
blockers = [
|
||||
make_state(make_combatant(f"blk{i}"), "monsters", pos)
|
||||
for i, pos in enumerate([(0, 1), (1, 0), (1, 1)])
|
||||
]
|
||||
engine = make_engine([5], [a, b, *blockers])
|
||||
engine._execute_maneuver(a, b, "grapple")
|
||||
assert b.pos == (0, 3)
|
||||
assert b.grappled_with is None
|
||||
assert a.grappled_with is None
|
||||
assert not any("grappled" in c.condition.name for c in b.active_conditions)
|
||||
assert any("FAILED (no adjacent space)" in line for line in engine._transcript)
|
||||
|
||||
|
||||
def test_adjacent_grapple_does_not_move_the_target() -> None:
|
||||
# Given: an already-adjacent target (it threatens — the maneuver provokes
|
||||
# an AoO, scripted as a natural 1 miss)
|
||||
# When: the grapple succeeds
|
||||
# Then: no pull happens; positions are untouched.
|
||||
a = make_state(make_combatant("a", attack_bonus=20), "players", (1, 1))
|
||||
b = make_state(make_combatant("b"), "monsters", (1, 2))
|
||||
engine = make_engine([1, 5], [a, b])
|
||||
engine._execute_maneuver(a, b, "grapple")
|
||||
assert b.pos == (1, 2)
|
||||
assert b.grappled_with == "a"
|
||||
assert not any("grapple pulls" in line for line in engine._transcript)
|
||||
|
||||
Reference in New Issue
Block a user