fix(rules): let a natural 20 auto-succeed on escape

PF1e (SRD Combat Maneuvers): "Rolling a natural 20 while attempting a combat maneuver is always a success (except when attempting to escape from bonds)" - escaping a grapple is a combat maneuver, not bonds. _maneuver_escape now auto-succeeds on a natural 20 below CMD (the natural 1 auto-failure is unchanged). README updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-12 08:58:11 +02:00
parent c96579a485
commit faa8a72a86
3 changed files with 52 additions and 4 deletions
+40
View File
@@ -122,6 +122,46 @@ def test_escape_from_grapple_consumes_single_d20() -> None:
assert not any(c.condition.name == "grappled" for c in a.active_conditions)
def _grappled_by(a: CombatantState, b: CombatantState) -> None:
a.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["grappled"],
rounds_left=1,
source="test",
)
)
a.grappled_with = b.combatant.id
def test_escape_natural_20_auto_succeeds_below_cmd() -> None:
# SRD Combat Maneuvers: "Rolling a natural 20 while attempting a combat
# maneuver is always a success (except when attempting to escape from
# bonds)" — a grapple is not bonds. Escape bonus 2, b CMD 30 (bab 20):
# 20+2=22 < 30, so only the natural-20 rule can succeed this.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b", attack_bonus=20), "monsters", (1, 2))
_grappled_by(a, b)
engine = make_engine([20], [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)
def test_escape_natural_1_still_fails_above_cmd() -> None:
# Natural 1 stays an automatic failure even when the total clears CMD:
# escape_artist rank 20 (+3 class) gives bonus 23 vs CMD 12.
a_spec = make_combatant("a", attack_bonus=2).model_copy(
update={"skills": {"escape_artist": SkillRank(rank=20, class_skill=True)}}
)
a = make_state(a_spec, "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
_grappled_by(a, b)
engine = make_engine([1], [a, b])
engine._maneuver_escape(a, b)
assert a.grappled_with == b.combatant.id
assert any(c.condition.name == "grappled" for c in a.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).