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:
@@ -1102,7 +1102,14 @@ Règles modélisées :
|
|||||||
your attack exceeds your opponent's CMD you can push the target back an
|
your attack exceeds your opponent's CMD you can push the target back an
|
||||||
additional 5 feet » — `1 + marge//5` cases, stoppées au premier obstacle
|
additional 5 feet » — `1 + marge//5` cases, stoppées au premier obstacle
|
||||||
(un 20 naturel sous le CMD ne donne que la poussée de base) ;
|
(un 20 naturel sous le CMD ne donne que la poussée de base) ;
|
||||||
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
|
grapple → `grappled`/`pinned` ; conformance L8 (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) » — une réussite à distance **tire la cible** sur la case
|
||||||
|
libre adjacente la plus proche (distance 5-10-5, départage (ligne,
|
||||||
|
colonne) ; journalisé `grapple pulls`), et **échoue** sans poser la
|
||||||
|
condition si aucune case adjacente libre n'existe (`FAILED (no adjacent
|
||||||
|
space)`) ; escape → **une** sauvegarde (bonus statique de la
|
||||||
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
|
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
|
||||||
sans boucle multi-tour. Conformance L4 (SRD « Combat Maneuvers ») : le 20
|
sans boucle multi-tour. Conformance L4 (SRD « Combat Maneuvers ») : le 20
|
||||||
naturel réussit **toujours** à l'escape d'un grapple (« Rolling a natural
|
naturel réussit **toujours** à l'escape d'un grapple (« Rolling a natural
|
||||||
|
|||||||
@@ -4438,12 +4438,67 @@ class CombatEngine:
|
|||||||
f"CMD {m.target_cmd} -> SUCCESS ({dmg} damage to weapon)"
|
f"CMD {m.target_cmd} -> SUCCESS ({dmg} damage to weapon)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _nearest_anchor_adjacent_to(
|
||||||
|
self, grappler: CombatantState, target: CombatantState
|
||||||
|
) -> Pos | None:
|
||||||
|
"""Closest free anchor where ``target``'s footprint is adjacent to
|
||||||
|
``grappler``'s occupied cells (SRD "Grapple" pull-to-adjacent).
|
||||||
|
|
||||||
|
Candidates are scored by 5-10-5 distance from the target's current
|
||||||
|
anchor; ties break on (row, col) for determinism. The target's own
|
||||||
|
current cells are excluded from the occupancy check (it is the thing
|
||||||
|
being moved), walls/out-of-bounds are never candidates, and adjacency
|
||||||
|
means the two footprints' cells are exactly one square apart.
|
||||||
|
"""
|
||||||
|
grappler_cells = grappler.occupied_cells()
|
||||||
|
best: tuple[int, Pos] | None = None
|
||||||
|
for row in range(self._grid.height):
|
||||||
|
for col in range(self._grid.width):
|
||||||
|
anchor: Pos = (row, col)
|
||||||
|
footprint = self._footprint_at(anchor, target)
|
||||||
|
if not all(
|
||||||
|
self._grid.passable(cell) for cell in footprint
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
blocked = self._blocked(grappler, target)
|
||||||
|
if footprint & (blocked | grappler_cells):
|
||||||
|
continue
|
||||||
|
if not any(
|
||||||
|
self._grid.distance(g, t) == 1
|
||||||
|
for g in grappler_cells
|
||||||
|
for t in footprint
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
dist = self._grid.distance(anchor, target.pos)
|
||||||
|
if best is None or (dist, anchor) < (best[0], best[1]):
|
||||||
|
best = (dist, anchor)
|
||||||
|
return None if best is None else best[1]
|
||||||
|
|
||||||
def _maneuver_grapple(
|
def _maneuver_grapple(
|
||||||
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||||
) -> None:
|
) -> None:
|
||||||
if not m.success:
|
if not m.success:
|
||||||
self._maneuver_fail(state, target, "grapple", m)
|
self._maneuver_fail(state, target, "grapple", m)
|
||||||
return
|
return
|
||||||
|
# PF1e (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)."
|
||||||
|
if self.creature_distance(state, target) > 1:
|
||||||
|
anchor = self._nearest_anchor_adjacent_to(state, target)
|
||||||
|
if anchor is None:
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: grapple vs "
|
||||||
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||||
|
f"CMD {m.target_cmd} -> FAILED (no adjacent space)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
start = target.pos
|
||||||
|
target.pos = anchor
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: grapple pulls "
|
||||||
|
f"{target.combatant.id} ({start[0]},{start[1]})->"
|
||||||
|
f"({anchor[0]},{anchor[1]})"
|
||||||
|
)
|
||||||
target.active_conditions.append(
|
target.active_conditions.append(
|
||||||
ActiveCondition(
|
ActiveCondition(
|
||||||
condition=CONDITIONS_BY_NAME["grappled"],
|
condition=CONDITIONS_BY_NAME["grappled"],
|
||||||
|
|||||||
@@ -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
|
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