diff --git a/README.md b/README.md index 9552e9f..8e8d8fe 100644 --- a/README.md +++ b/README.md @@ -1102,7 +1102,14 @@ Règles modélisées : 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 (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, sans boucle multi-tour. Conformance L4 (SRD « Combat Maneuvers ») : le 20 naturel réussit **toujours** à l'escape d'un grapple (« Rolling a natural diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index a6cd86e..c09c65b 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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"], diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index 0e10eec..412cb52 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -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)