diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 41a2171..9e9d15b 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -4641,6 +4641,15 @@ class CombatEngine: attacker_cmb = self._maneuver_cmb(state) - pa_amt - grapple_penalty target_cmd = self._maneuver_cmd(target) roll = self._rng.d20() + # PF1e (SRD Combat Maneuvers): "If your target is immobilized, + # unconscious, or otherwise incapacitated, your maneuver + # automatically succeeds (treat as if you rolled a natural 20 on + # the attack roll)." The engine's incapacitated notion is the + # helpless set (helpless/paralyzed/asleep/unconscious, CRB + # "Conditions"); the natural 20 also feeds the margin-based + # outcomes (bull rush / overrun / reposition / drag / dirty trick). + if self._is_helpless(target): + roll = _NATURAL_TWENTY # PF1e (CRB Combat Maneuvers): the provoked-AoO damage rides the # check total (floored at 0); natural 20/1 keep their rules below. total = max(0, roll + attacker_cmb - aoo_damage) diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index f4ed9fa..adae848 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -340,6 +340,44 @@ def test_disarm_fail_by_10_drops_attacker_weapon() -> None: assert any("disarm backfire -> drops sword" in line for line in engine._transcript) +# --- SRD Combat Maneuvers: auto-success against an incapacitated target ------- + + +def test_maneuver_auto_succeeds_vs_helpless_target() -> None: + # SRD Combat Maneuvers: "If your target is immobilized, unconscious, or + # otherwise incapacitated, your maneuver automatically succeeds (treat + # as if you rolled a natural 20 on the attack roll)." b (bab 20) has CMD + # 30: a natural 1 would normally auto-fail, but the helpless target + # forces the natural-20 treatment -> trip succeeds. + a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) + b = make_state(make_combatant("b", attack_bonus=20), "monsters", (1, 2)) + _conditioned(b, "paralyzed") + engine = make_engine([1, 1], [a, b]) + engine._execute_maneuver(a, b, "trip") + assert any(c.condition.name == "prone" for c in b.active_conditions) + assert "d20=20" in engine._transcript[-1] + + +def test_maneuver_auto_succeeds_vs_asleep_target_with_margin() -> None: + # The natural-20 treatment feeds the margin outcomes: a CMB 2 vs CMD 12, + # roll forced to 20 -> total 22, margin 10 -> bull rush pushes 3 cells. + a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) + b = make_state(make_combatant("b"), "monsters", (1, 2)) + _conditioned(b, "asleep") + engine = make_engine([1, 5], [a, b]) + engine._execute_maneuver(a, b, "bull_rush") + assert b.pos == (1, 5) + + +def test_maneuver_normal_roll_still_fails_vs_awake_target() -> None: + # Control: the same natural 1 against a non-helpless target auto-fails. + a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) + b = make_state(make_combatant("b"), "monsters", (1, 2)) + engine = make_engine([1, 1], [a, b]) + engine._execute_maneuver(a, b, "trip") + assert not any(c.condition.name == "prone" for c in b.active_conditions) + + def test_overrun_knocks_prone_and_moves_through() -> None: # d20=15+2=17 vs CMD 12: margin 5 -> prone AND moved through (CRB # "Overrun": prone only when the attack exceeds CMD by 5 or more).