diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index ab758df..630edf2 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -177,6 +177,7 @@ _CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge _CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging _CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares) _UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft +_GRAPPLED_CMB_PENALTY = 2 # PF1e: grappled/pinned -2 on non-grapple/escape CMB # PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons _POINT_BLANK_RANGE_FT = 30 _FIRING_MELEE_PENALTY = -4 # PF1e: ranged attack into melee @@ -4580,7 +4581,22 @@ class CombatEngine: # check is not penalized (a skill check is not a CMB check) — see # _maneuver_escape. pa_amt = self._power_attack_amt(state) - attacker_cmb = self._maneuver_cmb(state) - pa_amt + # PF1e (SRD Conditions - Grappled / Pinned): a grappled or pinned + # creature takes a -2 penalty on "all attack rolls AND combat + # maneuver checks, except for those made to escape or break a + # grapple". Escape returned to its own handler above; initiating a + # grapple is the exempt check. _maneuver_cmb stays a pure stat + # (the concentration DC reads the grappler's CMB, not a check). + grapple_penalty = ( + _GRAPPLED_CMB_PENALTY + if maneuver != "grapple" + and ( + self._has_condition_named(state, "grappled") + or self._has_condition_named(state, "pinned") + ) + else 0 + ) + attacker_cmb = self._maneuver_cmb(state) - pa_amt - grapple_penalty target_cmd = self._maneuver_cmd(target) roll = self._rng.d20() # PF1e (CRB Combat Maneuvers): the provoked-AoO damage rides the diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index 412cb52..afeac05 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -671,3 +671,63 @@ def test_adjacent_grapple_does_not_move_the_target() -> None: assert b.pos == (1, 2) assert b.grappled_with == "a" assert not any("grapple pulls" in line for line in engine._transcript) + + +# --- Grappled / pinned -2 on combat maneuver checks -------------------------- + + +def _conditioned(a: CombatantState, name: str) -> None: + a.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME[name], rounds_left=1, source="test" + ) + ) + + +def test_grappled_attacker_takes_minus2_on_trip_cmb() -> None: + # SRD Conditions - Grappled: "-2 penalty on all attack rolls and combat + # maneuver checks, except for those made to escape or break a grapple." + # a CMB 2 - 2 = 0 vs b CMD 12: d20 10 -> total 10 < 12 -> FAILED (the + # pre-fix engine would have succeeded at 10 + 2 = 12). + a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) + b = make_state(make_combatant("b"), "monsters", (1, 2)) + _conditioned(a, "grappled") + engine = make_engine([1, 10], [a, b]) + engine._execute_maneuver(a, b, "trip") + assert not any(c.condition.name == "prone" for c in b.active_conditions) + assert engine._transcript[-1].endswith("-> FAILED") + + +def test_pinned_attacker_takes_minus2_on_trip_cmb() -> None: + # Same -2 applies to a pinned creature (SRD Conditions - Pinned). + a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) + b = make_state(make_combatant("b"), "monsters", (1, 2)) + _conditioned(a, "pinned") + engine = make_engine([1, 10], [a, b]) + engine._execute_maneuver(a, b, "trip") + assert not any(c.condition.name == "prone" for c in b.active_conditions) + assert engine._transcript[-1].endswith("-> FAILED") + + +def test_grappled_attacker_grapple_check_is_exempt() -> None: + # The -2 does NOT apply to grapple checks (escape or break a grapple): + # d20 10 + CMB 2 = 12 >= CMD 12 -> SUCCESS. + a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) + b = make_state(make_combatant("b"), "monsters", (1, 2)) + _conditioned(a, "grappled") + engine = make_engine([1, 10], [a, b]) + engine._execute_maneuver(a, b, "grapple") + assert b.grappled_with == "a" + assert engine._transcript[-1].endswith("SUCCESS (grappled)") + + +def test_grappled_escape_check_is_exempt() -> None: + # Escape is handled by _maneuver_escape which never applies the penalty: + # escape bonus = CMB 2 (no -2), d20 10 -> total 12 >= CMD 12 -> freed. + a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) + b = make_state(make_combatant("b"), "monsters", (1, 2)) + _grappled_by(a, b) + engine = make_engine([10], [a, b]) + engine._maneuver_escape(a, b) + assert a.grappled_with is None + assert not any(c.condition.name == "grappled" for c in a.active_conditions)