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:
@@ -4438,12 +4438,67 @@ class CombatEngine:
|
||||
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(
|
||||
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||
) -> None:
|
||||
if not m.success:
|
||||
self._maneuver_fail(state, target, "grapple", m)
|
||||
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(
|
||||
ActiveCondition(
|
||||
condition=CONDITIONS_BY_NAME["grappled"],
|
||||
|
||||
Reference in New Issue
Block a user